Repository: akuity/kargo

Issue:
bug: errant reference to erstwhile magical behavior of kustomize-set-image step
This bit of docs references some "magical" behavior from the pre-expression days.

WRT to the `tag` field:

> A tag naming a specific revision of image. Mutually exclusive with digest. If none of these are specified, the tag specified by a piece of Freight referencing image will be used as the value of this field.

And WRT `digest`:

> A digest naming a specific revision of image. Mutually exclusive with tag. If none of these are specified, the tag specified by a piece of Freight referencing image will be used as the value of tag.

https://docs.kargo.io/user-guide/reference-docs/promotion-steps/kustomize-set-image/#configuration

But this magical behavior was deprecated in v1.1.0 and removed in v1.3.0, as documented here:

https://docs.kargo.io/release-notes/v1.3.0/

We need to get the `kustomize-set-image` docs caught up to reality.

Discussion:

Solution patch:
diff --git a/Makefile b/Makefile
index 161cc4a7..d3d3d76f 100644
--- a/Makefile
+++ b/Makefile
@@ -54,6 +54,8 @@ ifeq ($(GOARCH), x86_64)
 	override GOARCH = amd64
 endif
 
+KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME ?=
+
 ################################################################################
 # Tests                                                                        #
 #                                                                              #
@@ -417,6 +419,10 @@ hack-uninstall-argocd: install-helm
 hack-uninstall-cert-manager: install-helm
 	$(HELM) delete cert-manager --namespace cert-manager
 
+.PHONY: hack-ngrok
+hack-ngrok:
+	ngrok http --hostname=$(KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME) 30083
+
 .PHONY: start-api-local
 start-api-local:
 	./hack/start-api.sh
diff --git a/Tiltfile b/Tiltfile
index 59963398..cbd35d3c 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -54,7 +54,8 @@ k8s_yaml(
     './charts/kargo',
     name = 'kargo',
     namespace = 'kargo',
-    values = 'hack/tilt/values.dev.yaml'
+    values = 'hack/tilt/values.dev.yaml',
+    set = 'externalWebhooksServer.host=' + os.environ.get('KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME', 'localhost:30083')
   )
 )
 # Normally the API server serves up the front end, but we want live updates
@@ -124,6 +125,21 @@ k8s_resource(
   ]
 )
 
+k8s_resource(
+  workload = 'kargo-external-webhooks-server',
+  new_name = 'external-webhooks-server',
+  port_forwards = [
+    '30083:8080'
+  ],
+  labels = ['kargo'],
+  objects = [
+    'kargo-external-webhooks-server:clusterrole',
+    'kargo-external-webhooks-server:clusterrolebinding',
+    'kargo-external-webhooks-server:configmap',
+    'kargo-external-webhooks-server:serviceaccount'
+  ],
+)
+
 k8s_resource(
   workload = 'kargo-garbage-collector',
   new_name = 'garbage-collector',
@@ -162,7 +178,7 @@ k8s_resource(
 
 k8s_resource(
   workload = 'kargo-webhooks-server',
-  new_name = 'webhooks-server',
+  new_name = 'kubernetes-webhooks-server',
   labels = ['kargo'],
   objects = [
     'kargo:mutatingwebhookconfiguration',
@@ -183,9 +199,12 @@ k8s_resource(
 k8s_resource(
   new_name = 'crds',
   objects = [
+    'clusterpromotiontasks.kargo.akuity.io:customresourcedefinition',
     'freights.kargo.akuity.io:customresourcedefinition',
+    'projectconfigs.kargo.akuity.io:customresourcedefinition',
     'projects.kargo.akuity.io:customresourcedefinition',
     'promotions.kargo.akuity.io:customresourcedefinition',
+    'promotiontasks.kargo.akuity.io:customresourcedefinition',
     'stages.kargo.akuity.io:customresourcedefinition',
     'warehouses.kargo.akuity.io:customresourcedefinition'
   ],
diff --git a/api/v1alpha1/promotion_types.go b/api/v1alpha1/promotion_types.go
index eb9c5105..75cb9878 100644
--- a/api/v1alpha1/promotion_types.go
+++ b/api/v1alpha1/promotion_types.go
@@ -288,9 +288,9 @@ func (s *PromotionStep) GetAlias(i int) string {
 	case s.As != "":
 		return s.As
 	case s.Task != nil:
-		return fmt.Sprintf("task-%d", i)
+		return fmt.Sprintf("task-%d", i+1)
 	default:
-		return fmt.Sprintf("step-%d", i)
+		return fmt.Sprintf("step-%d", i+1)
 	}
 }
 
diff --git a/charts/kargo/README.md b/charts/kargo/README.md
index ae4b7540..6b48d981 100644
--- a/charts/kargo/README.md
+++ b/charts/kargo/README.md
@@ -184,6 +184,39 @@ the Kargo controller is running.
 | `controller.env`                                                   | Environment variables to add to controller pods.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `[]`                |
 | `controller.envFrom`                                               | Environment variables to add to controller pods from ConfigMaps or Secrets.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `[]`                |
 
+### External Webhooks Server
+
+| Name                                                | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Value                    |
+| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
+| `externalWebhooksServer.enabled`                    | Whether the external webhooks server is enabled.                                                                                                                                                                                                                                                                                                                                                                                                                       | `true`                   |
+| `externalWebhooksServer.replicas`                   | The number of external webhooks server pods.                                                                                                                                                                                                                                                                                                                                                                                                                           | `1`                      |
+| `externalWebhooksServer.host`                       | The domain name where Kargo's external webhooks server will be accessible. When applicable, this is used for generation of an Ingress resource and certificates. Note: The value in this field MAY include a port number and MUST NOT specify the protocol (http vs https), which is automatically inferred from other configuration options.                                                                                                                          | `localhost`              |
+| `externalWebhooksServer.logLevel`                   | The log level for the external webhooks server.                                                                                                                                                                                                                                                                                                                                                                                                                        | `INFO`                   |
+| `externalWebhooksServer.labels`                     | Labels to add to the external webhook server resources. Merges with `global.labels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                                                                                                             | `{}`                     |
+| `externalWebhooksServer.annotations`                | Annotations to add to the external webhook server resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                                                                                              | `{}`                     |
+| `externalWebhooksServer.podLabels`                  | Optional labels to add to the external webhook server pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                                                                                                      | `{}`                     |
+| `externalWebhooksServer.podAnnotations`             | Optional annotations to add to the external webhook server pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                                                                                       | `{}`                     |
+| `externalWebhooksServer.resources`                  | Resources limits and requests for the external webhook server containers.                                                                                                                                                                                                                                                                                                                                                                                              | `{}`                     |
+| `externalWebhooksServer.nodeSelector`               | Node selector for external webhook server pods. Defaults to `global.nodeSelector`.                                                                                                                                                                                                                                                                                                                                                                                     | `{}`                     |
+| `externalWebhooksServer.tolerations`                | Tolerations for external webhook server pods. Defaults to `global.tolerations`.                                                                                                                                                                                                                                                                                                                                                                                        | `[]`                     |
+| `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.env`                        | Environment variables to add to external webhook server pods.                                                                                                                                                                                                                                                                                                                                                                                                          | `[]`                     |
+| `externalWebhooksServer.envFrom`                    | Environment variables to add to external webhook server pods from ConfigMaps or Secrets.                                                                                                                                                                                                                                                                                                                                                                               | `[]`                     |
+| `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.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.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`                   |
+| `externalWebhooksServer.ingress.tls.selfSignedCert` | Whether to generate a self-signed certificate for use with the external webhook server's Ingress resource. 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-ingress-cert` **must** be provided in the same namespace as Kargo. The value in this field has no effect if `externalWebhooksServer.ingress.tls.enabled` is `false`. | `true`                   |
+| `externalWebhooksServer.ingress.pathType`           | You may want to use `Prefix` for some controllers (like AWS LoadBalancer Ingress controller), which don't support `/` as wildcard path when pathType is set to `ImplementationSpecific`                                                                                                                                                                                                                                                                                | `ImplementationSpecific` |
+| `externalWebhooksServer.service.type`               | If you're not going to use an ingress controller, you may want to change this value to `LoadBalancer` for production deployments. If running locally, you may want to change it to `NodePort` OR leave it as `ClusterIP` and use `kubectl port-forward` to map a port on the local network interface to the service.                                                                                                                                                   | `ClusterIP`              |
+| `externalWebhooksServer.service.nodePort`           | Host port the `Service` will be mapped to when `type` is either `NodePort` or `LoadBalancer`. If not specified, Kubernetes chooses.                                                                                                                                                                                                                                                                                                                                    | `undefined`              |
+| `externalWebhooksServer.service.annotations`        | Annotations to add to the external webhook server's service. Merges with `global.annotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                                                                                              | `{}`                     |
+
 ### Management Controller
 
 | Name                                                                       | Description                                                                                                                                                                          | Value  |
@@ -220,10 +253,10 @@ the Kargo controller is running.
 | `webhooksServer.replicas`              | The number of webhooks server pods.                                                                                                                                                                                                                                                                                                                                                   | `1`    |
 | `webhooksServer.logLevel`              | The log level for the webhooks server.                                                                                                                                                                                                                                                                                                                                                | `INFO` |
 | `webhooksServer.controlplaneUserRegex` | Regular expression for matching controlplane users.                                                                                                                                                                                                                                                                                                                                   | `""`   |
-| `webhooksServer.labels`                | Labels to add to the api resources. Merges with `global.labels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                                                | `{}`   |
-| `webhooksServer.annotations`           | Annotations to add to the api resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                                 | `{}`   |
-| `webhooksServer.podLabels`             | Optional labels to add to pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                                                 | `{}`   |
-| `webhooksServer.podAnnotations`        | Optional annotations to add to pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                                  | `{}`   |
+| `webhooksServer.labels`                | Labels to add to the webhook server resources. Merges with `global.labels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                                     | `{}`   |
+| `webhooksServer.annotations`           | Annotations to add to the webhook server resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                                      | `{}`   |
+| `webhooksServer.podLabels`             | Optional labels to add to the webhook server pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.                                                                                                                                                                                                                                              | `{}`   |
+| `webhooksServer.podAnnotations`        | Optional annotations to add to the webhook server pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.                                                                                                                                                                                                                               | `{}`   |
 | `webhooksServer.tls.selfSignedCert`    | Whether to generate a self-signed certificate for the controller's built-in webhook 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-webhooks-server-cert` **must** be provided in the same namespace as Kargo. There is no provision for webhooks without TLS. | `true` |
 | `webhooksServer.resources`             | Resources limits and requests for the webhooks server containers.                                                                                                                                                                                                                                                                                                                     | `{}`   |
 | `webhooksServer.nodeSelector`          | Node selector for the webhooks server pods. Defaults to `global.nodeSelector`.                                                                                                                                                                                                                                                                                                        | `{}`   |
diff --git a/charts/kargo/templates/_helpers.tpl b/charts/kargo/templates/_helpers.tpl
index d4eff2fb..ff750ea6 100644
--- a/charts/kargo/templates/_helpers.tpl
+++ b/charts/kargo/templates/_helpers.tpl
@@ -78,16 +78,20 @@ app.kubernetes.io/component: controller
 app.kubernetes.io/component: dex-server
 {{- end -}}
 
+{{- define "kargo.externalWebhooksServer.labels" -}}
+app.kubernetes.io/component: external-webhooks-server
+{{- end -}}
+
 {{- define "kargo.garbageCollector.labels" -}}
 app.kubernetes.io/component: garbage-collector
 {{- end -}}
 
-{{- define "kargo.managementController.labels" -}}
-app.kubernetes.io/component: management-controller
+{{- define "kargo.kubernetesWebhooksServer.labels" -}}
+app.kubernetes.io/component: kubernetes-webhooks-server
 {{- end -}}
 
-{{- define "kargo.webhooksServer.labels" -}}
-app.kubernetes.io/component: webhooks-server
+{{- define "kargo.managementController.labels" -}}
+app.kubernetes.io/component: management-controller
 {{- end -}}
 
 {{/*
diff --git a/charts/kargo/templates/common/cert-issuer.yaml b/charts/kargo/templates/common/cert-issuer.yaml
index 226cb0cb..89edaeb0 100644
--- a/charts/kargo/templates/common/cert-issuer.yaml
+++ b/charts/kargo/templates/common/cert-issuer.yaml
@@ -1,5 +1,5 @@
 
-{{- if or (and .Values.api.enabled .Values.api.ingress.enabled .Values.api.ingress.tls.enabled .Values.api.ingress.tls.selfSignedCert) (and .Values.webhooksServer.enabled .Values.webhooksServer.tls.selfSignedCert) (and .Values.api.enabled .Values.api.oidc.enabled .Values.api.oidc.dex.enabled .Values.api.oidc.dex.tls.selfSignedCert) }}
+{{- if or (and .Values.api.enabled .Values.api.tls.enabled .Values.api.tls.selfSignedCert) (and .Values.api.enabled .Values.api.ingress.enabled .Values.api.ingress.tls.enabled .Values.api.ingress.tls.selfSignedCert) (and .Values.api.enabled .Values.api.oidc.enabled .Values.api.oidc.dex.enabled .Values.api.oidc.dex.tls.selfSignedCert) (and .Values.externalWebhooksServer.enabled .Values.externalWebhooksServer.tls.enabled .Values.externalWebhooksServer.tls.selfSignedCert) (and .Values.externalWebhooksServer.enabled .Values.externalWebhooksServer.ingress.enabled .Values.externalWebhooksServer.ingress.tls.enabled .Values.externalWebhooksServer.ingress.tls.selfSignedCert) (and .Values.webhooksServer.enabled .Values.webhooksServer.tls.selfSignedCert) }}
 apiVersion: cert-manager.io/v1
 kind: Issuer
 metadata:
diff --git a/charts/kargo/templates/external-webhooks-server/cert.yaml b/charts/kargo/templates/external-webhooks-server/cert.yaml
new file mode 100644
index 00000000..b4fa2b9b
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/cert.yaml
@@ -0,0 +1,17 @@
+{{- if and .Values.externalWebhooksServer.enabled .Values.externalWebhooksServer.tls.enabled .Values.externalWebhooksServer.tls.selfSignedCert }}
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+spec:
+  dnsNames:
+  - {{ quote .Values.externalWebhooksServer.host }}
+  issuerRef:
+    kind: Issuer
+    name: kargo-selfsigned-cert-issuer
+  secretName: kargo-external-webhooks-server-cert
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/cluster-role-binding.yaml b/charts/kargo/templates/external-webhooks-server/cluster-role-binding.yaml
new file mode 100644
index 00000000..0fb52794
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/cluster-role-binding.yaml
@@ -0,0 +1,17 @@
+{{- if and .Values.externalWebhooksServer.enabled .Values.rbac.installClusterRoleBindings }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: kargo-external-webhooks-server
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: kargo-external-webhooks-server
+subjects:
+- kind: ServiceAccount
+  namespace: {{ .Release.Namespace }}
+  name: kargo-external-webhooks-server
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/cluster-role.yaml b/charts/kargo/templates/external-webhooks-server/cluster-role.yaml
new file mode 100644
index 00000000..058d5e38
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/cluster-role.yaml
@@ -0,0 +1,19 @@
+{{- if and .Values.externalWebhooksServer.enabled .Values.rbac.installClusterRoles }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: kargo-external-webhooks-server
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+rules:
+- apiGroups:
+  - kargo.akuity.io
+  resources:
+  - warehouses
+  verbs:
+  - get
+  - list
+  - patch
+  - watch
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/configmap.yaml b/charts/kargo/templates/external-webhooks-server/configmap.yaml
new file mode 100644
index 00000000..e6efc2f5
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/configmap.yaml
@@ -0,0 +1,21 @@
+{{- if .Values.externalWebhooksServer.enabled }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+data:
+  KARGO_NAMESPACE: {{ .Release.Namespace }}
+  LOG_LEVEL: {{ quote .Values.externalWebhooksServer.logLevel }}
+  {{- if .Values.kubeconfigSecrets.kargo }}
+  KUBECONFIG: /etc/kargo/kubeconfig.yaml
+  {{- end }}
+  {{- if .Values.externalWebhooksServer.tls.enabled }}
+  TLS_ENABLED: "true"
+  TLS_CERT_PATH: /etc/kargo/tls.crt
+  TLS_KEY_PATH: /etc/kargo/tls.key
+  {{- end }}
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/deployment.yaml b/charts/kargo/templates/external-webhooks-server/deployment.yaml
new file mode 100644
index 00000000..30c2e8a5
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/deployment.yaml
@@ -0,0 +1,122 @@
+{{- if .Values.webhooksServer.enabled }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+  {{- with (mergeOverwrite (deepCopy .Values.global.labels) .Values.externalWebhooksServer.labels) }}
+    {{- range $key, $value := . }}
+    {{ $key }}: {{ $value | quote }}
+    {{- end }}
+  {{- end }}
+  {{- with (mergeOverwrite (deepCopy .Values.global.annotations) .Values.externalWebhooksServer.annotations) }}
+  annotations:
+    {{- range $key, $value := . }}
+    {{ $key }}: {{ $value | quote }}
+    {{- end }}
+  {{- end }}
+spec:
+  replicas: {{ .Values.externalWebhooksServer.replicas | default 1 }}
+  strategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      {{- include "kargo.selectorLabels" . | nindent 6 }}
+      {{- include "kargo.externalWebhooksServer.labels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        {{- include "kargo.labels" . | nindent 8 }}
+        {{- include "kargo.externalWebhooksServer.labels" . | nindent 8 }}
+      {{- with (mergeOverwrite (deepCopy .Values.global.podLabels) .Values.externalWebhooksServer.podLabels) }}
+        {{- range $key, $value := . }}
+        {{ $key }}: {{ $value | quote }}
+        {{- end }}
+      {{- end }}
+      annotations:
+        configmap/checksum: {{ pick ( include (print $.Template.BasePath "/external-webhooks-server/configmap.yaml") . | fromYaml ) "data" | toYaml | sha256sum }}
+      {{- with (mergeOverwrite (deepCopy .Values.global.podAnnotations) .Values.externalWebhooksServer.podAnnotations) }}
+        {{- range $key, $value := . }}
+        {{ $key }}: {{ $value | quote }}
+        {{- end }}
+      {{- end }}
+    spec:
+      serviceAccount: kargo-webhooks-server
+      {{- with .Values.externalWebhooksServer.affinity | default .Values.global.affinity }}
+      affinity:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.image.pullSecrets }}
+      imagePullSecrets:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      containers:
+      - name: webhooks-server
+        image: {{ include "kargo.image" . }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        command: ["/sbin/tini", "--", "/usr/local/bin/kargo"]
+        args: ["external-webhooks-server"]
+        env:
+        - name: GOMEMLIMIT
+          valueFrom:
+            resourceFieldRef:
+              containerName: webhooks-server
+              divisor: "1"
+              resource: limits.memory
+        - name: GOMAXPROCS
+          valueFrom:
+            resourceFieldRef:
+              containerName: webhooks-server
+              divisor: "1"
+              resource: {{ include "kargo.selectCpuResourceField" (dict "resources" .Values.externalWebhooksServer.resources) }}
+        {{- with (concat .Values.global.env .Values.externalWebhooksServer.env) }}
+        {{- toYaml . | nindent 8 }}
+        {{- end }}
+        envFrom:
+        - configMapRef:
+            name: kargo-webhooks-server
+        {{- with (concat .Values.global.envFrom .Values.externalWebhooksServer.envFrom) }}
+          {{- toYaml . | nindent 8 }}
+        {{- end }}
+        ports:
+        - containerPort: 9443
+          name: webhooks-server
+          protocol: TCP
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+        {{- if .Values.kubeconfigSecrets.kargo }}
+        - mountPath: /etc/kargo/kubeconfigs
+          name: kubeconfigs
+          readOnly: true
+        {{- end }}
+        {{- with .Values.externalWebhooksServer.securityContext | default .Values.global.securityContext }}
+        securityContext:
+          {{- toYaml . | nindent 10 }}
+        {{- end }}
+        resources:
+          {{- toYaml .Values.externalWebhooksServer.resources | nindent 10 }}
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 0644
+          secretName: kargo-webhooks-server-cert
+      {{- if .Values.kubeconfigSecrets.kargo }}
+      - name: kubeconfigs
+        secret:
+          defaultMode: 0644
+          secretName: {{ .Values.kubeconfigSecrets.kargo }}
+      {{- end }}
+      {{- with .Values.externalWebhooksServer.nodeSelector | default .Values.global.nodeSelector }}
+      nodeSelector:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.externalWebhooksServer.tolerations | default .Values.global.tolerations }}
+      tolerations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/ingress-cert.yaml b/charts/kargo/templates/external-webhooks-server/ingress-cert.yaml
new file mode 100644
index 00000000..96cfebff
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/ingress-cert.yaml
@@ -0,0 +1,17 @@
+{{- if and .Values.externalWebhooksServer.enabled .Values.externalWebhooksServer.ingress.enabled .Values.externalWebhooksServer.ingress.tls.enabled .Values.externalWebhooksServer.ingress.tls.selfSignedCert }}
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: kargo-external-webhooks-server-ingress
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+spec:
+  dnsNames:
+  - {{ quote .Values.externalWebhooksServer.host }}
+  issuerRef:
+    kind: Issuer
+    name: kargo-selfsigned-cert-issuer
+  secretName: kargo-external-webhooks-server-ingress-cert
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/ingress.yaml b/charts/kargo/templates/external-webhooks-server/ingress.yaml
new file mode 100644
index 00000000..e142b20b
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/ingress.yaml
@@ -0,0 +1,41 @@
+{{- if and .Values.externalWebhooksServer.enabled .Values.externalWebhooksServer.ingress.enabled }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+  {{- with (mergeOverwrite (deepCopy .Values.global.annotations) .Values.externalWebhooksServer.ingress.annotations) }}
+  annotations:
+    {{- range $key, $value := . }}
+    {{ $key }}: {{ $value | quote }}
+    {{- end }}
+  {{- end }}
+spec:
+  {{- if .Values.externalWebhooksServer.ingress.ingressClassName }}
+  ingressClassName: {{ .Values.externalWebhooksServer.ingress.ingressClassName }}
+  {{- end }}
+  rules:
+  - host: {{ quote .Values.externalWebhooksServer.host }}
+    http:
+      paths:
+      - pathType: {{ .Values.externalWebhooksServer.ingress.pathType | default "ImplementationSpecific" }}
+        path: /
+        backend:
+          service:
+            name: kargo-external-webhooks-server
+            port:
+              {{- if .Values.externalWebhooksServer.tls.enabled }}
+              number: 443
+              {{- else }}
+              number: 80
+              {{- end }}
+  {{- if .Values.externalWebhooksServer.ingress.tls.enabled }}
+  tls:
+  - hosts:
+    - {{ quote .Values.externalWebhooksServer.host }}
+    secretName: kargo-external-webhooks-server-ingress-cert
+  {{- end }}
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/service-account.yaml b/charts/kargo/templates/external-webhooks-server/service-account.yaml
new file mode 100644
index 00000000..b4c8cf9f
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/service-account.yaml
@@ -0,0 +1,10 @@
+{{- if .Values.externalWebhooksServer.enabled }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+{{- end }}
diff --git a/charts/kargo/templates/external-webhooks-server/service.yaml b/charts/kargo/templates/external-webhooks-server/service.yaml
new file mode 100644
index 00000000..51ef89b9
--- /dev/null
+++ b/charts/kargo/templates/external-webhooks-server/service.yaml
@@ -0,0 +1,32 @@
+{{- if .Values.webhooksServer.enabled }}
+apiVersion: v1
+kind: Service
+metadata:
+  name: kargo-external-webhooks-server
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "kargo.labels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+  {{- with (mergeOverwrite (deepCopy .Values.global.annotations) .Values.externalWebhooksServer.service.annotations) }}
+  annotations:
+    {{- range $key, $value := . }}
+    {{ $key }}: {{ $value | quote }}
+    {{- end }}
+  {{- end }}
+spec:
+  type: {{ .Values.externalWebhooksServer.service.type }}
+  ports:
+  - protocol: TCP
+    {{- if .Values.externalWebhooksServer.tls.enabled }}
+    port: 443
+    {{- else }}
+    port: 80
+    {{- end }}
+    {{- if and (or (eq .Values.externalWebhooksServer.service.type "NodePort") (eq .Values.externalWebhooksServer.service.type "LoadBalancer")) .Values.externalWebhooksServer.service.nodePort}}
+    nodePort: {{ .Values.externalWebhooksServer.service.nodePort }}
+    {{- end }}
+    targetPort: 8080
+  selector:
+    {{- include "kargo.selectorLabels" . | nindent 4 }}
+    {{- include "kargo.externalWebhooksServer.labels" . | nindent 4 }}
+{{- end }}
diff --git a/charts/kargo/templates/webhooks-server/cert.yaml b/charts/kargo/templates/kubernetes-webhooks-server/cert.yaml
similarity index 100%
rename from charts/kargo/templates/webhooks-server/cert.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/cert.yaml
diff --git a/charts/kargo/templates/webhooks-server/cluster-role-binding.yaml b/charts/kargo/templates/kubernetes-webhooks-server/cluster-role-binding.yaml
similarity index 84%
rename from charts/kargo/templates/webhooks-server/cluster-role-binding.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/cluster-role-binding.yaml
index abda6088..0ca5de65 100644
--- a/charts/kargo/templates/webhooks-server/cluster-role-binding.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/cluster-role-binding.yaml
@@ -5,7 +5,7 @@ metadata:
   name: kargo-webhooks-server
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 roleRef:
   apiGroup: rbac.authorization.k8s.io
   kind: ClusterRole
@@ -21,7 +21,7 @@ metadata:
   name: kargo-webhooks-server-ns-controller
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 roleRef:
   apiGroup: rbac.authorization.k8s.io
   kind: ClusterRole
@@ -37,7 +37,7 @@ metadata:
   name: kargo-webhooks-server-generic-gc
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 roleRef:
   apiGroup: rbac.authorization.k8s.io
   kind: ClusterRole
diff --git a/charts/kargo/templates/webhooks-server/cluster-role.yaml b/charts/kargo/templates/kubernetes-webhooks-server/cluster-role.yaml
similarity index 91%
rename from charts/kargo/templates/webhooks-server/cluster-role.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/cluster-role.yaml
index 20751789..d68b92ee 100644
--- a/charts/kargo/templates/webhooks-server/cluster-role.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/cluster-role.yaml
@@ -5,7 +5,7 @@ metadata:
   name: kargo-webhooks-server
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 rules:
 - apiGroups:
   - ""
@@ -78,7 +78,7 @@ metadata:
   name: kargo-webhooks-server-ns-controller
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 rules:
 - apiGroups:
   - kargo.akuity.io
@@ -99,7 +99,7 @@ metadata:
   name: kargo-webhooks-server-generic-gc
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 rules:
 - apiGroups:
   - kargo.akuity.io
diff --git a/charts/kargo/templates/webhooks-server/configmap.yaml b/charts/kargo/templates/kubernetes-webhooks-server/configmap.yaml
similarity index 90%
rename from charts/kargo/templates/webhooks-server/configmap.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/configmap.yaml
index dc33f77b..96bd3907 100644
--- a/charts/kargo/templates/webhooks-server/configmap.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/configmap.yaml
@@ -6,7 +6,7 @@ metadata:
   namespace: {{ .Release.Namespace }}
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 data:
   KARGO_NAMESPACE: {{ .Release.Namespace }}
   LOG_LEVEL: {{ quote .Values.webhooksServer.logLevel }}
diff --git a/charts/kargo/templates/webhooks-server/deployment.yaml b/charts/kargo/templates/kubernetes-webhooks-server/deployment.yaml
similarity index 91%
rename from charts/kargo/templates/webhooks-server/deployment.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/deployment.yaml
index 47c8680a..edc0ad77 100644
--- a/charts/kargo/templates/webhooks-server/deployment.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/deployment.yaml
@@ -6,7 +6,7 @@ metadata:
   namespace: {{ .Release.Namespace }}
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
   {{- with (mergeOverwrite (deepCopy .Values.global.labels) .Values.webhooksServer.labels) }}
     {{- range $key, $value := . }}
     {{ $key }}: {{ $value | quote }}
@@ -25,19 +25,19 @@ spec:
   selector:
     matchLabels:
       {{- include "kargo.selectorLabels" . | nindent 6 }}
-      {{- include "kargo.webhooksServer.labels" . | nindent 6 }}
+      {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 6 }}
   template:
     metadata:
       labels:
         {{- include "kargo.labels" . | nindent 8 }}
-        {{- include "kargo.webhooksServer.labels" . | nindent 8 }}
+        {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 8 }}
       {{- with (mergeOverwrite (deepCopy .Values.global.podLabels) .Values.webhooksServer.podLabels) }}
         {{- range $key, $value := . }}
         {{ $key }}: {{ $value | quote }}
         {{- end }}
       {{- end }}
       annotations:
-        configmap/checksum: {{ pick ( include (print $.Template.BasePath "/webhooks-server/configmap.yaml") . | fromYaml ) "data" | toYaml | sha256sum }}
+        configmap/checksum: {{ pick ( include (print $.Template.BasePath "/kubernetes-webhooks-server/configmap.yaml") . | fromYaml ) "data" | toYaml | sha256sum }}
       {{- with (mergeOverwrite (deepCopy .Values.global.podAnnotations) .Values.webhooksServer.podAnnotations) }}
         {{- range $key, $value := . }}
         {{ $key }}: {{ $value | quote }}
@@ -58,7 +58,7 @@ spec:
         image: {{ include "kargo.image" . }}
         imagePullPolicy: {{ .Values.image.pullPolicy }}
         command: ["/sbin/tini", "--", "/usr/local/bin/kargo"]
-        args: ["webhooks-server"]
+        args: ["kubernetes-webhooks-server"]
         env:
         - name: GOMEMLIMIT
           valueFrom:
diff --git a/charts/kargo/templates/webhooks-server/service-account.yaml b/charts/kargo/templates/kubernetes-webhooks-server/service-account.yaml
similarity index 75%
rename from charts/kargo/templates/webhooks-server/service-account.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/service-account.yaml
index 08e675bf..ed0dbcac 100644
--- a/charts/kargo/templates/webhooks-server/service-account.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/service-account.yaml
@@ -6,5 +6,5 @@ metadata:
   namespace: {{ .Release.Namespace }}
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 {{- end }}
diff --git a/charts/kargo/templates/webhooks-server/service.yaml b/charts/kargo/templates/kubernetes-webhooks-server/service.yaml
similarity index 71%
rename from charts/kargo/templates/webhooks-server/service.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/service.yaml
index 386e3c5f..f8bef20e 100644
--- a/charts/kargo/templates/webhooks-server/service.yaml
+++ b/charts/kargo/templates/kubernetes-webhooks-server/service.yaml
@@ -6,7 +6,7 @@ metadata:
   namespace: {{ .Release.Namespace }}
   labels:
     {{- include "kargo.labels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 spec:
   type: ClusterIP
   ports:
@@ -15,5 +15,5 @@ spec:
     targetPort: 9443
   selector:
     {{- include "kargo.selectorLabels" . | nindent 4 }}
-    {{- include "kargo.webhooksServer.labels" . | nindent 4 }}
+    {{- include "kargo.kubernetesWebhooksServer.labels" . | nindent 4 }}
 {{- end }}
diff --git a/charts/kargo/templates/webhooks/webhooks.yaml b/charts/kargo/templates/kubernetes-webhooks-server/webhooks.yaml
similarity index 100%
rename from charts/kargo/templates/webhooks/webhooks.yaml
rename to charts/kargo/templates/kubernetes-webhooks-server/webhooks.yaml
diff --git a/charts/kargo/values.yaml b/charts/kargo/values.yaml
index 0d53a31a..866fdff8 100755
--- a/charts/kargo/values.yaml
+++ b/charts/kargo/values.yaml
@@ -495,6 +495,92 @@ controller:
   #  - secretRef:
   #      name: secret-name
 
+## @section External Webhooks Server
+externalWebhooksServer:
+  ## @param externalWebhooksServer.enabled Whether the external webhooks server is enabled.
+  enabled: true
+  ## @param externalWebhooksServer.replicas The number of external webhooks server pods.
+  replicas: 1
+  ## @param externalWebhooksServer.host The domain name where Kargo's external webhooks server will be accessible. When applicable, this is used for generation of an Ingress resource and certificates. Note: The value in this field MAY include a port number and MUST NOT specify the protocol (http vs https), which is automatically inferred from other configuration options.
+  host: localhost
+  ## @param externalWebhooksServer.logLevel The log level for the external webhooks server.
+  logLevel: INFO
+
+  ## @param externalWebhooksServer.labels Labels to add to the external webhook server resources. Merges with `global.labels`, allowing you to override or add to the global labels.
+  labels: {}
+  ## @param externalWebhooksServer.annotations Annotations to add to the external webhook server resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.
+  annotations: {}
+  ## @param externalWebhooksServer.podLabels Optional labels to add to the external webhook server pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.
+  podLabels: {}
+  ## @param externalWebhooksServer.podAnnotations Optional annotations to add to the external webhook server pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.
+  podAnnotations: {}
+
+  ## @param externalWebhooksServer.resources Resources limits and requests for the external webhook server containers.
+  resources: {}
+    # limits:
+    #   cpu: 100m
+    #   memory: 128Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 128Mi
+  
+  ## @param externalWebhooksServer.nodeSelector Node selector for external webhook server pods. Defaults to `global.nodeSelector`.
+  nodeSelector: {}
+  ## @param externalWebhooksServer.tolerations Tolerations for external webhook server pods. Defaults to `global.tolerations`.
+  tolerations: []
+  ## @param externalWebhooksServer.affinity Specifies pod affinity for external webhook server pods. Defaults to `global.affinity`.
+  affinity: {}
+  
+  ## @param externalWebhooksServer.securityContext Security context for external webhook server pods. Defaults to `global.securityContext`.
+  securityContext: {}
+  
+  ## @param externalWebhooksServer.env Environment variables to add to external webhook server pods.
+  env: []
+  #  - name: ENV_NAME
+  #    value: value
+  ## @param externalWebhooksServer.envFrom Environment variables to add to external webhook server pods from ConfigMaps or Secrets.
+  envFrom: []
+  #  - configMapRef:
+  #      name: config-map-name
+  #  - secretRef:
+  #      name: secret-name
+
+  probes:
+    ## @param 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.
+    enabled: true
+
+  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
+    ## @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.
+    enabled: false
+    ## @param externalWebhooksServer.ingress.annotations Annotations specified by your ingress controller to customize the behavior of the Ingress resource.
+    annotations: {}
+      # kubernetes.io/ingress.class: nginx
+    ## @param 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.
+    ingressClassName:
+    tls:
+      ## @param externalWebhooksServer.ingress.tls.enabled Whether to associate a certificate with the Ingress resource.
+      enabled: true
+      ## @param externalWebhooksServer.ingress.tls.selfSignedCert Whether to generate a self-signed certificate for use with the external webhook server's Ingress resource. 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-ingress-cert` **must** be provided in the same namespace as Kargo. The value in this field has no effect if `externalWebhooksServer.ingress.tls.enabled` is `false`.
+      selfSignedCert: true
+    ## @param externalWebhooksServer.ingress.pathType You may want to use `Prefix` for some controllers (like AWS LoadBalancer Ingress controller), which don't support `/` as wildcard path when pathType is set to `ImplementationSpecific`
+    pathType: ImplementationSpecific
+
+  service:
+    ## @param externalWebhooksServer.service.type If you're not going to use an ingress controller, you may want to change this value to `LoadBalancer` for production deployments. If running locally, you may want to change it to `NodePort` OR leave it as `ClusterIP` and use `kubectl port-forward` to map a port on the local network interface to the service.
+    type: ClusterIP
+    ## @param externalWebhooksServer.service.nodePort [nullable] Host port the `Service` will be mapped to when `type` is either `NodePort` or `LoadBalancer`. If not specified, Kubernetes chooses.
+    # nodePort:
+    ## @param externalWebhooksServer.service.annotations Annotations to add to the external webhook server's service. Merges with `global.annotations`, allowing you to override or add to the global annotations.
+    annotations: {}
+
 ## @section Management Controller
 ## All settings for the management controller component
 managementController:
@@ -571,18 +657,19 @@ webhooksServer:
   ## @param webhooksServer.controlplaneUserRegex Regular expression for matching controlplane users.
   controlplaneUserRegex: "" # ^system:serviceaccount:kargo:[a-z0-9]([-a-z0-9]*[a-z0-9])?$
 
-  ## @param webhooksServer.labels Labels to add to the api resources. Merges with `global.labels`, allowing you to override or add to the global labels.
+  ## @param webhooksServer.labels Labels to add to the webhook server resources. Merges with `global.labels`, allowing you to override or add to the global labels.
   labels: {}
-  ## @param webhooksServer.annotations Annotations to add to the api resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.
+  ## @param webhooksServer.annotations Annotations to add to the webhook server resources. Merges with `global.annotations`, allowing you to override or add to the global annotations.
   annotations: {}
-  ## @param webhooksServer.podLabels Optional labels to add to pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.
+  ## @param webhooksServer.podLabels Optional labels to add to the webhook server pods. Merges with `global.podLabels`, allowing you to override or add to the global labels.
   podLabels: {}
-  ## @param webhooksServer.podAnnotations Optional annotations to add to pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.
+  ## @param webhooksServer.podAnnotations Optional annotations to add to the webhook server pods. Merges with `global.podAnnotations`, allowing you to override or add to the global annotations.
   podAnnotations: {}
 
   tls:
     ## @param webhooksServer.tls.selfSignedCert  Whether to generate a self-signed certificate for the controller's built-in webhook 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-webhooks-server-cert` **must** be provided in the same namespace as Kargo. There is no provision for webhooks without TLS.
     selfSignedCert: true
+
   ## @param webhooksServer.resources Resources limits and requests for the webhooks server containers.
   resources: {}
     # limits:
diff --git a/cmd/controlplane/external_webhooks.go b/cmd/controlplane/external_webhooks.go
new file mode 100644
index 00000000..5aac6409
--- /dev/null
+++ b/cmd/controlplane/external_webhooks.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"runtime"
+
+	"github.com/spf13/cobra"
+	libCluster "sigs.k8s.io/controller-runtime/pkg/cluster"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/indexer"
+	"github.com/akuity/kargo/internal/logging"
+	"github.com/akuity/kargo/internal/os"
+	"github.com/akuity/kargo/internal/server/kubernetes"
+	"github.com/akuity/kargo/internal/webhook/external"
+	versionpkg "github.com/akuity/kargo/pkg/x/version"
+)
+
+type externalWebhooksServerOptions struct {
+	KubeConfig string
+
+	Host string
+	Port string
+
+	Logger *logging.Logger
+}
+
+func newExternalWebhooksServerCommand() *cobra.Command {
+	cmdOpts := &externalWebhooksServerOptions{
+		// During startup, we enforce use of an info-level logger to ensure that
+		// no important startup messages are missed.
+		Logger: logging.NewLogger(logging.InfoLevel),
+	}
+
+	cmd := &cobra.Command{
+		Use:               "external-webhooks-server",
+		DisableAutoGenTag: true,
+		SilenceErrors:     true,
+		SilenceUsage:      true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			cmdOpts.complete()
+
+			return cmdOpts.run(cmd.Context())
+		},
+	}
+
+	return cmd
+}
+
+func (o *externalWebhooksServerOptions) complete() {
+	o.KubeConfig = os.GetEnv("KUBECONFIG", "")
+	o.Host = os.GetEnv("HOST", "0.0.0.0")
+	o.Port = os.GetEnv("PORT", "8080")
+}
+
+func (o *externalWebhooksServerOptions) run(ctx context.Context) error {
+	version := versionpkg.GetVersion()
+	o.Logger.Info(
+		"Starting Kargo External Webhooks Server",
+		"version", version.Version,
+		"commit", version.GitCommit,
+		"GOMAXPROCS", runtime.GOMAXPROCS(0),
+		"GOMEMLIMIT", os.GetEnv("GOMEMLIMIT", ""),
+	)
+
+	serverCfg := external.ServerConfigFromEnv()
+
+	restCfg, err := kubernetes.GetRestConfig(ctx, o.KubeConfig)
+	if err != nil {
+		return fmt.Errorf("error getting Kubernetes client REST config: %w", err)
+	}
+
+	cluster, err := libCluster.New(restCfg)
+	if err != nil {
+		return fmt.Errorf("error creating Kubernetes client: %w", err)
+	}
+
+	if err = kargoapi.AddToScheme(cluster.GetClient().Scheme()); err != nil {
+		return fmt.Errorf("error adding Kargo API to scheme: %w", err)
+	}
+
+	err = cluster.GetFieldIndexer().IndexField(
+		ctx,
+		&kargoapi.Warehouse{},
+		indexer.WarehousesBySubscribedURLsField,
+		indexer.WarehousesBySubscribedURLs,
+	)
+	if err != nil {
+		return fmt.Errorf("error registering warehouse by repo url indexer: %w", err)
+	}
+
+	srv := external.NewServer(serverCfg, cluster.GetClient())
+	l, err := net.Listen("tcp", fmt.Sprintf("%s:%s", o.Host, o.Port))
+	if err != nil {
+		return fmt.Errorf("error creating listener: %w", err)
+	}
+	defer l.Close()
+
+	if err = srv.Serve(ctx, l); err != nil {
+		return fmt.Errorf("error serving API: %w", err)
+	}
+	return nil
+}
diff --git a/cmd/controlplane/webhooks.go b/cmd/controlplane/kubernetes_webhooks.go
similarity index 79%
rename from cmd/controlplane/webhooks.go
rename to cmd/controlplane/kubernetes_webhooks.go
index a5d2d6c2..acd35cf6 100644
--- a/cmd/controlplane/webhooks.go
+++ b/cmd/controlplane/kubernetes_webhooks.go
@@ -19,18 +19,18 @@ import (
 	"github.com/akuity/kargo/internal/logging"
 	"github.com/akuity/kargo/internal/os"
 	"github.com/akuity/kargo/internal/server/kubernetes"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
-	"github.com/akuity/kargo/internal/webhook/freight"
-	"github.com/akuity/kargo/internal/webhook/project"
-	"github.com/akuity/kargo/internal/webhook/projectconfig"
-	"github.com/akuity/kargo/internal/webhook/promotion"
-	"github.com/akuity/kargo/internal/webhook/promotiontask"
-	"github.com/akuity/kargo/internal/webhook/stage"
-	"github.com/akuity/kargo/internal/webhook/warehouse"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/freight"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/project"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/projectconfig"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/promotion"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/promotiontask"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/stage"
+	"github.com/akuity/kargo/internal/webhook/kubernetes/warehouse"
 	versionpkg "github.com/akuity/kargo/pkg/x/version"
 )
 
-type webhooksServerOptions struct {
+type kubernetesWebhooksServerOptions struct {
 	KubeConfig string
 
 	MetricsBindAddress string
@@ -39,15 +39,15 @@ type webhooksServerOptions struct {
 	Logger *logging.Logger
 }
 
-func newWebhooksServerCommand() *cobra.Command {
-	cmdOpts := &webhooksServerOptions{
+func newKubernetesWebhooksServerCommand() *cobra.Command {
+	cmdOpts := &kubernetesWebhooksServerOptions{
 		// During startup, we enforce use of an info-level logger to ensure that
 		// no important startup messages are missed.
 		Logger: logging.NewLogger(logging.InfoLevel),
 	}
 
 	cmd := &cobra.Command{
-		Use:               "webhooks-server",
+		Use:               "kubernetes-webhooks-server",
 		DisableAutoGenTag: true,
 		SilenceErrors:     true,
 		SilenceUsage:      true,
@@ -61,16 +61,16 @@ func newWebhooksServerCommand() *cobra.Command {
 	return cmd
 }
 
-func (o *webhooksServerOptions) complete() {
+func (o *kubernetesWebhooksServerOptions) complete() {
 	o.KubeConfig = os.GetEnv("KUBECONFIG", "")
 	o.MetricsBindAddress = os.GetEnv("METRICS_BIND_ADDRESS", "0")
 	o.PprofBindAddress = os.GetEnv("PPROF_BIND_ADDRESS", "")
 }
 
-func (o *webhooksServerOptions) run(ctx context.Context) error {
+func (o *kubernetesWebhooksServerOptions) run(ctx context.Context) error {
 	version := versionpkg.GetVersion()
 	o.Logger.Info(
-		"Starting Kargo Webhooks Server",
+		"Starting Kargo Kubernetes Webhooks Server",
 		"version", version.Version,
 		"commit", version.GitCommit,
 		"GOMAXPROCS", stdruntime.GOMAXPROCS(0),
@@ -153,7 +153,7 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 	}
 
 	if err := mgr.Start(ctx); err != nil {
-		return fmt.Errorf("start Kargo webhook manager: %w", err)
+		return fmt.Errorf("start Kargo Kubernetes webhook manager: %w", err)
 	}
 	return nil
 }
diff --git a/cmd/controlplane/root.go b/cmd/controlplane/root.go
index ac10eac6..b4bdf54d 100644
--- a/cmd/controlplane/root.go
+++ b/cmd/controlplane/root.go
@@ -21,9 +21,10 @@ var (
 func Execute(ctx context.Context) error {
 	rootCmd.AddCommand(newAPICommand())
 	rootCmd.AddCommand(newControllerCommand())
+	rootCmd.AddCommand(newExternalWebhooksServerCommand())
 	rootCmd.AddCommand(newGarbageCollectorCommand())
+	rootCmd.AddCommand(newKubernetesWebhooksServerCommand())
 	rootCmd.AddCommand(newManagementControllerCommand())
 	rootCmd.AddCommand(newVersionCommand())
-	rootCmd.AddCommand(newWebhooksServerCommand())
 	return rootCmd.ExecuteContext(ctx)
 }
diff --git a/docs/docs/50-user-guide/50-security/30-managing-credentials.md b/docs/docs/50-user-guide/50-security/30-managing-credentials.md
index 640a411d..3b253d5a 100644
--- a/docs/docs/50-user-guide/50-security/30-managing-credentials.md
+++ b/docs/docs/50-user-guide/50-security/30-managing-credentials.md
@@ -414,6 +414,7 @@ metadata:
   labels:
     kargo.akuity.io/cred-type: image
 stringData:
+  awsRegion: us-west-2
   awsAccessKeyID: <access key id>
   awsSecretAccessKey: <secret access key>
   repoURL: <ecr url>
diff --git a/docs/docs/50-user-guide/60-reference-docs/15-promotion-templates.md b/docs/docs/50-user-guide/60-reference-docs/15-promotion-templates.md
index e80c9c8d..70b030b1 100644
--- a/docs/docs/50-user-guide/60-reference-docs/15-promotion-templates.md
+++ b/docs/docs/50-user-guide/60-reference-docs/15-promotion-templates.md
@@ -224,27 +224,111 @@ steps:
 
 #### Conditional Steps
 
-You can conditionally execute a step based on the result of a previous step by
+You can conditionally execute a step based on the results of previous steps by
 using the `if` key in the step definition. The value of the `if` key must be a
-valid [expression](40-expressions.md) that evaluates to a boolean value.
+valid [expression](40-expressions.md) that evaluates to a boolean value. If the
+expression evaluates to `true`, the step is executed as normal. If the
+expression evaluates to `false`, the step is skipped and the next step in the
+sequence is executed.
+
+These conditionals may make use of special functions whose returned values
+depend on the outcome of all previous steps:
+
+- `always()`: Unconditionally evaluates to `true`.
+- `failure()`: Evaluates to `true` only if _any_ previous step has errored or
+  failed.
+- `success()`: Evaluates to `true` only if _all_ previous steps have completed
+  successfully or been skipped.
+
+Examples:
 
 ```yaml
 steps:
-- uses: step-name
-  if: ${{ outputs.step1.someOutput == 'value' }}
+- uses: some-step # Assume this step succeeds
+- uses: some-other-step
+  if: ${{ always() }} # This step will execute; assume it succeeds
+- uses: and-another-step
+  if: ${{ failure() }} # This step will be skipped
+- uses: yet-another-step
+  if: ${{ success() }} # This step will execute; assume it succeeds
+
+# This Promotion will succeed
 ```
 
-If the expression evaluates to `true`, the step is executed as normal. If the
-expression evaluates to `false`, the step is skipped and the next step in the
-sequence is executed.
+```yaml
+steps:
+- uses: some-step # Assume this step fails
+- uses: some-other-step
+  if: ${{ always() }} # This step will execute
+- uses: and-another-step
+  if: ${{ failure() }} # This step will execute  
+- uses: yet-another-step
+  if: ${{ success() }} # This step will be skipped
+
+# This Promotion will fail
+```
 
-:::info
-In a future release, Kargo will be adding support for improved failure and
-error handling, which will supercharge the ability to conditionally execute
-steps based on the outcome of previous steps.
+It is also possible to directly access the status of a specific step using the
+`status()` function with a step alias as an argument:
+
+```yaml
+steps:
+- uses: some-step # Assume this step encounters an error
+  as: my-step
+- uses: some-other-step
+  if: ${{ always() }} # This step will execute
+- uses: and-another-step
+  if: ${{ failure() }} # This step will execute
+- uses: yet-another-step
+  if: ${{ status('my-step') == 'Errored' }} # This step will execute
+
+# This Promotion will fail
+```
 
-Refer to [this issue](https://github.com/akuity/kargo/issues/3228) for more
-information and updates.
+A step's `continueOnError` field can be set to `true` to indicate that an error
+or failure should _both_:
+
+- Not be counted as an error or failure when evaluating the `failure()` or
+  `success()` functions.
+- Not affect the overall outcome of the `Promotion`.
+
+Example:
+
+```yaml
+steps:
+- uses: some-step # Assume this step fails
+  continueOnError: true
+- uses: some-other-step
+  if: ${{ always() }} # This step will execute; assume it succeeds
+- uses: and-another-step
+  if: ${{ failure() }} # This step will be skipped
+- uses: yet-another-step
+  if: ${{ success() }} # This step will execute; assume it succeeds
+
+# This Promotion will succeed
+```
+
+Last, when a step's `if` field is empty, the default behavior is to execute the
+step only if _all_ previous steps have either completed successfully, been
+skipped, or completed with some other status, but had `continueOnError` set to
+`true`.
+
+Examples:
+
+```yaml
+steps:
+- uses: some-step # Assume this step succeeds
+- uses: some-other-step # This step will execute; assume it fails
+  continueOnError: true
+- uses: and-another-step # This step will execute; assume it succeeds
+- uses: yet-another-step # This step will execute; assume it succeeds
+
+# This Promotion will succeed
+```
+
+:::tip
+Using the primitives described above, it is possible to create robust error-handling
+logic in your promotion templates.
 :::
 
 :::tip
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/argocd-update.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/argocd-update.md
index 37fb27d6..6decded7 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/argocd-update.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/argocd-update.md
@@ -48,7 +48,8 @@ step to honor sync windows, you must use Argo CD v2.11.0 or later.
 _Additionally, it is recommended that if a promotion process is expected to
 sometimes encounter an active deny window, the `argocd-update` step should be
 configured with a timeout that is at least as long as the longest expected deny
-window. (The step's default timeout is five minutes.)_
+window. The step's default timeout of five minutes can be overridden using the
+[`retry.timeout`](../15-promotion-templates.md#step-retries) field.
 :::
 
 ## Configuration
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/kustomize-set-image.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/kustomize-set-image.md
index 3c8d1d61..3dbf3142 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/kustomize-set-image.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/kustomize-set-image.md
@@ -17,8 +17,8 @@ to executing `kustomize edit set image`. This step is commonly followed by a
 | `path` | `string` | Y | Path to a directory containing a `kustomization.yaml` file. This path is relative to the temporary workspace that Kargo provisions for use by the promotion process.                                                                                                                                                                         |
 | `images` | `[]object` | N | The details of changes to be applied to the `kustomization.yaml` file. When left unspecified, all images from the Freight collection will be set in the Kustomization file. Unless there is an ambiguous image name (for example, due to two Warehouses subscribing to the same repository), which requires manual configuration.            |
 | `images[].image` | `string` | Y | Name/URL of the image being updated.                                                                                                                                                                                                                                                                                                         |
-| `images[].tag` | `string` | N | A tag naming a specific revision of `image`. Mutually exclusive with `digest`. If none of these are specified, the tag specified by a piece of Freight referencing `image` will be used as the value of this field.                                                                                                                          |
-| `images[].digest` | `string` | N | A digest naming a specific revision of `image`. Mutually exclusive with `tag`. If none of these are specified, the tag specified by a piece of Freight referencing `image` will be used as the value of `tag`.                                                                                                                               |
+| `images[].tag` | `string` | N | A tag naming a specific revision of `image`. Mutually exclusive with `digest`. This field can be specified directly or through an [expression](../../60-reference-docs/40-expressions.md#imagefromrepourl-freightorigin). Either `tag` or `digest` must be provided, but not both. |
+| `images[].digest` | `string` | N | A digest naming a specific revision of `image`. Mutually exclusive with `tag`. This field can be specified directly or through an [expression](../../60-reference-docs/40-expressions.md#imagefromrepourl-freightorigin). Either `digest` or `tag` must be provided, but not both. |
 | `images[].newName` | `string` | N | A substitution for the name/URL of the image being updated. This is useful when different Stages have access to different container image repositories (assuming those different repositories contain equivalent images that are tagged identically). This may be a frequent consideration for users of Amazon's Elastic Container Registry. |
 
 ## Output
diff --git a/docs/docs/60-contributor-guide/10-hacking-on-kargo.md b/docs/docs/60-contributor-guide/10-hacking-on-kargo.md
index 6788c824..d87bb50e 100644
--- a/docs/docs/60-contributor-guide/10-hacking-on-kargo.md
+++ b/docs/docs/60-contributor-guide/10-hacking-on-kargo.md
@@ -280,6 +280,40 @@ as native processes.
     You may safely ignore any certificate warnings.
     :::
 
+1. Optional: Configure and start a tunnel for the external webhooks server:
+
+    If you are working on or testing the external webhooks server, you will
+    benefit from configuring a tunnel from the outside world so that traffic
+    originating from platforms like GitHub, Docker Hub, and others can reach the
+    server. It is easy to accomplish this using [ngrok](https://ngrok.com/).
+    If you wish to do so, you must first
+    [install ngrok](https://ngrok.com/downloads) yourself.
+
+    With `ngrok` installed, you can conveniently open a tunnel to
+    `localhost:30083` (where the next step will run the external webhooks
+    server) using:
+
+    ```shell
+    make hack-ngrok
+    ```
+
+    Allow this process to run while you are working on or testing the external
+    webhooks server. Interrupt it with `ctrl + c` when you are done.
+
+    If you have a paid ngrok account that allows you to use a custom domain name
+    for your tunnels, you can specify that domain name using the
+    `KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME` environment variable before running
+    the make target:
+
+    ```shell
+    export KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME=my-tunnel.ngrok.io
+    make hack-ngrok
+    ```
+
+    If the `KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME` environment variable is
+    undefined, the tunnel will utilize a dynamically-generated subdomain of
+    `ngrok.io`.
+
 1. Build and deploy Kargo from source:
 
     [Tilt](https://docs.tilt.dev/#macoslinux) is a convenient tool that builds
@@ -310,6 +344,19 @@ as native processes.
     altered. They can be rebuilt and replaced with a single click.
     :::
 
+    :::info
+    If you specified a custom domain name for a tunnel to the external webhooks
+    server in the previous step by defining a value for the
+    `KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME` environment variable, you should
+    export the same value for that environment variable before running `tilt up`
+    as well:
+
+    ```shell
+    export KARGO_EXTERNAL_WEBHOOKS_SERVER_HOSTNAME=my-tunnel.ngrok.io
+    tilt up
+    ```
+    :::
+
 1. If necessary, build the CLI from source:
 
     ```shell
diff --git a/hack/tilt/values.dev.yaml b/hack/tilt/values.dev.yaml
index bdb71f6b..959913a8 100644
--- a/hack/tilt/values.dev.yaml
+++ b/hack/tilt/values.dev.yaml
@@ -33,9 +33,16 @@ controller:
   logLevel: DEBUG
 crds:
   install: true
+externalWebhooksServer:
+  logLevel: DEBUG
+  host: localhost:30083
+  tls:
+    enabled: false
+  probes:
+    enabled: false
+garbageCollector:
+  logLevel: DEBUG
 managementController:
   logLevel: DEBUG
 webhooksServer:
   logLevel: DEBUG
-garbageCollector:
-  logLevel: DEBUG
\ No newline at end of file
diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go
index c33255fd..0b157bae 100644
--- a/internal/indexer/indexer.go
+++ b/internal/indexer/indexer.go
@@ -15,6 +15,8 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	libargocd "github.com/akuity/kargo/internal/argocd"
 	"github.com/akuity/kargo/internal/expressions"
+	"github.com/akuity/kargo/internal/git"
+	"github.com/akuity/kargo/internal/helm"
 	"github.com/akuity/kargo/internal/logging"
 	"github.com/akuity/kargo/internal/promotion"
 )
@@ -38,6 +40,8 @@ const (
 	StagesByWarehouseField      = "warehouse"
 
 	ServiceAccountsByOIDCClaimsField = "claims"
+
+	WarehousesBySubscribedURLsField = "subscribedURLs"
 )
 
 // EventsByInvolvedObjectAPIGroup is a client.IndexerFunc that indexes
@@ -443,3 +447,36 @@ func ServiceAccountsByOIDCClaims(obj client.Object) []string {
 	}
 	return refinedClaimValues
 }
+
+// WarehousesBySubscribedURLs is a client.IndexerFunc that indexes Warehouses by the
+// repositories they subscribe to.
+func WarehousesBySubscribedURLs(obj client.Object) []string {
+	warehouse, ok := obj.(*kargoapi.Warehouse)
+	if !ok {
+		return nil
+	}
+
+	var repoURLs []string
+	for _, sub := range warehouse.Spec.Subscriptions {
+		if sub.Git != nil && sub.Git.RepoURL != "" {
+			repoURLs = append(repoURLs,
+				git.NormalizeURL(sub.Git.RepoURL),
+			)
+		}
+		if sub.Chart != nil && sub.Chart.RepoURL != "" {
+			repoURLs = append(repoURLs,
+				helm.NormalizeChartRepositoryURL(sub.Chart.RepoURL),
+			)
+		}
+		if sub.Image != nil && sub.Image.RepoURL != "" {
+			repoURLs = append(repoURLs,
+				// TODO(fuskovic): This chart URL normalization logic is adequate for
+				// normalizing image URLs in the near term, but should eventually be
+				// replaced with dedicated image URL normalization logic.
+				// See https://github.com/akuity/kargo/issues/3999
+				helm.NormalizeChartRepositoryURL(sub.Image.RepoURL),
+			)
+		}
+	}
+	return repoURLs
+}
diff --git a/internal/kargo/promotion_builder.go b/internal/kargo/promotion_builder.go
index a368bf66..c952ead8 100644
--- a/internal/kargo/promotion_builder.go
+++ b/internal/kargo/promotion_builder.go
@@ -113,6 +113,7 @@ func (b *PromotionBuilder) InflateSteps(ctx context.Context, promo *kargoapi.Pro
 			}
 			steps = append(steps, taskSteps...)
 		default:
+			step.As = step.GetAlias(i)
 			steps = append(steps, step)
 		}
 	}
diff --git a/internal/promotion/simple_engine.go b/internal/promotion/simple_engine.go
index 3c8834de..af8304bc 100644
--- a/internal/promotion/simple_engine.go
+++ b/internal/promotion/simple_engine.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"os"
 	"regexp"
-	"strings"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -131,21 +130,11 @@ stepLoop:
 		default:
 		}
 
-		// Prepare the step for execution by setting the alias.
-		if step.Alias, err = e.stepAlias(step.Alias, i); err != nil {
-			stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
-			stepExecMeta.Message = fmt.Sprintf("error getting step alias: %s", err)
-			// Continue, because despite this failure, some steps' "if" conditions may
-			// still allow them to run.
-			continue
-		}
-		stepExecMeta.Alias = step.Alias
-
 		// Check if the step should be skipped.
 		var skip bool
 		if skip, err = step.Skip(ctx, e.kargoClient, promoCtx, state); err != nil {
 			stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
-			stepExecMeta.Message = fmt.Sprintf("error checking if step %d should be skipped: %s", i, err)
+			stepExecMeta.Message = fmt.Sprintf("error checking if step %q should be skipped: %s", step.Alias, err)
 			// Continue, because despite this failure, some steps' "if" conditions may
 			// still allow them to run.
 			continue
@@ -196,7 +185,7 @@ stepLoop:
 			// Deal with statuses that no step should have returned.
 			stepExecMeta.FinishedAt = ptr.To(metav1.Now())
 			stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
-			stepExecMeta.Message = fmt.Sprintf("step %d returned an invalid status: %s", i, result.Status)
+			stepExecMeta.Message = fmt.Sprintf("step %q returned an invalid status: %s", step.Alias, result.Status)
 			// Continue, because despite this failure, some steps' "if" conditions may
 			// still allow them to run.
 			continue
@@ -221,7 +210,7 @@ stepLoop:
 			if message == "" {
 				message = "no details provided"
 			}
-			err = fmt.Errorf("step %d errored: %s", i, message)
+			err = fmt.Errorf("step %q errored: %s", step.Alias, message)
 		}
 
 		// At this point, we've sorted out any discrepancies between the status and
@@ -253,7 +242,7 @@ stepLoop:
 				stepExecMeta.FinishedAt = ptr.To(metav1.Now())
 				stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
 				stepExecMeta.Message = fmt.Sprintf(
-					"step %d met error threshold of %d: %s", i,
+					"step %q met error threshold of %d: %s", step.Alias,
 					errorThreshold, stepExecMeta.Message,
 				)
 				// Continue, because despite this failure, some steps' "if" conditions
@@ -272,7 +261,7 @@ stepLoop:
 			// Timeout has elapsed.
 			stepExecMeta.FinishedAt = ptr.To(metav1.Now())
 			stepExecMeta.Status = kargoapi.PromotionStepStatusErrored
-			stepExecMeta.Message = fmt.Sprintf("step %d timed out after %s", i, timeout.String())
+			stepExecMeta.Message = fmt.Sprintf("step %q timed out after %s", step.Alias, timeout.String())
 			// Continue, because despite this failure, some steps' "if" conditions may
 			// still allow them to run.
 			continue
@@ -369,7 +358,7 @@ func (e *simpleEngine) executeStep(
 
 	result, err := runner.Run(ctx, stepCtx)
 	if err != nil {
-		err = fmt.Errorf("error running step %q: %w", step.Kind, err)
+		err = fmt.Errorf("error running step %q: %w", step.Alias, err)
 	}
 	return result, err
 }
@@ -403,21 +392,6 @@ func (e *simpleEngine) prepareStepContext(
 	}, nil
 }
 
-// stepAlias returns the alias for a step. If the alias is empty, a default
-// alias is returned based on the step index.
-func (e *simpleEngine) stepAlias(alias string, index int64) (string, error) {
-	if alias = strings.TrimSpace(alias); alias != "" {
-		// A webhook enforces this regex as well, but we're checking here to
-		// account for the possibility of EXISTING Stages with a promotionTemplate
-		// containing a step with a now-reserved alias.
-		if ReservedStepAliasRegex.MatchString(alias) {
-			return "", fmt.Errorf("step alias %q is forbidden", alias)
-		}
-		return alias, nil
-	}
-	return fmt.Sprintf("step-%d", index), nil
-}
-
 // setupWorkDir creates a temporary working directory if one is not provided.
 func (e *simpleEngine) setupWorkDir(existingDir string) (string, error) {
 	if existingDir != "" {
diff --git a/internal/webhook/external/config.go b/internal/webhook/external/config.go
new file mode 100644
index 00000000..5889bb54
--- /dev/null
+++ b/internal/webhook/external/config.go
@@ -0,0 +1,40 @@
+package external
+
+import (
+	"time"
+
+	"github.com/kelseyhightower/envconfig"
+
+	"github.com/akuity/kargo/internal/os"
+	"github.com/akuity/kargo/internal/types"
+)
+
+type StandardConfig struct {
+	GracefulShutdownTimeout time.Duration `envconfig:"GRACEFUL_SHUTDOWN_TIMEOUT" default:"30s"`
+}
+
+type ServerConfig struct {
+	StandardConfig
+	TLSConfig *TLSConfig
+}
+
+func ServerConfigFromEnv() ServerConfig {
+	cfg := ServerConfig{}
+	envconfig.MustProcess("", &cfg.StandardConfig)
+	if types.MustParseBool(os.GetEnv("TLS_ENABLED", "false")) {
+		tlsCfg := TLSConfigFromEnv()
+		cfg.TLSConfig = &tlsCfg
+	}
+	return cfg
+}
+
+type TLSConfig struct {
+	CertPath string `envconfig:"TLS_CERT_PATH" required:"true"`
+	KeyPath  string `envconfig:"TLS_KEY_PATH" required:"true"`
+}
+
+func TLSConfigFromEnv() TLSConfig {
+	cfg := TLSConfig{}
+	envconfig.MustProcess("", &cfg)
+	return cfg
+}
diff --git a/internal/webhook/external/server.go b/internal/webhook/external/server.go
new file mode 100644
index 00000000..a680151f
--- /dev/null
+++ b/internal/webhook/external/server.go
@@ -0,0 +1,73 @@
+package external
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/http"
+	"time"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/akuity/kargo/internal/logging"
+)
+
+type server struct {
+	cfg    ServerConfig
+	client client.Client
+}
+
+type Server interface {
+	Serve(ctx context.Context, l net.Listener) error
+}
+
+func NewServer(cfg ServerConfig, cl client.Client) Server {
+	return &server{
+		cfg:    cfg,
+		client: cl,
+	}
+}
+
+func (s *server) Serve(ctx context.Context, l net.Listener) error {
+	logger := logging.LoggerFromContext(ctx)
+	mux := http.NewServeMux()
+
+	// TODO: Register handlers here
+	// mux.Handle("/", ...)
+
+	srv := &http.Server{
+		Handler:           mux,
+		ReadHeaderTimeout: time.Minute,
+	}
+
+	errCh := make(chan error)
+	go func() {
+		if s.cfg.TLSConfig != nil {
+			errCh <- srv.ServeTLS(
+				l,
+				s.cfg.TLSConfig.CertPath,
+				s.cfg.TLSConfig.KeyPath,
+			)
+		} else {
+			errCh <- srv.Serve(l)
+		}
+	}()
+
+	logger.Info(
+		"Server is listening",
+		"tls", s.cfg.TLSConfig != nil,
+		"address", l.Addr().String(),
+	)
+
+	select {
+	case <-ctx.Done():
+		logger.Info("Gracefully stopping server...")
+		time.Sleep(s.cfg.GracefulShutdownTimeout)
+		return srv.Shutdown(context.Background())
+	case err := <-errCh:
+		if errors.Is(err, http.ErrServerClosed) {
+			return nil
+		}
+		return err
+	}
+}
diff --git a/internal/webhook/admission.go b/internal/webhook/kubernetes/admission.go
similarity index 100%
rename from internal/webhook/admission.go
rename to internal/webhook/kubernetes/admission.go
diff --git a/internal/webhook/config.go b/internal/webhook/kubernetes/config.go
similarity index 100%
rename from internal/webhook/config.go
rename to internal/webhook/kubernetes/config.go
diff --git a/internal/webhook/freight/aliases.go b/internal/webhook/kubernetes/freight/aliases.go
similarity index 100%
rename from internal/webhook/freight/aliases.go
rename to internal/webhook/kubernetes/freight/aliases.go
diff --git a/internal/webhook/freight/webhook.go b/internal/webhook/kubernetes/freight/webhook.go
similarity index 99%
rename from internal/webhook/freight/webhook.go
rename to internal/webhook/kubernetes/freight/webhook.go
index ee338fde..49dcd249 100644
--- a/internal/webhook/freight/webhook.go
+++ b/internal/webhook/kubernetes/freight/webhook.go
@@ -25,7 +25,7 @@ import (
 	"github.com/akuity/kargo/internal/helm"
 	"github.com/akuity/kargo/internal/indexer"
 	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 var (
diff --git a/internal/webhook/project.go b/internal/webhook/kubernetes/project.go
similarity index 100%
rename from internal/webhook/project.go
rename to internal/webhook/kubernetes/project.go
diff --git a/internal/webhook/project/webhook.go b/internal/webhook/kubernetes/project/webhook.go
similarity index 100%
rename from internal/webhook/project/webhook.go
rename to internal/webhook/kubernetes/project/webhook.go
diff --git a/internal/webhook/projectconfig/webhook.go b/internal/webhook/kubernetes/projectconfig/webhook.go
similarity index 100%
rename from internal/webhook/projectconfig/webhook.go
rename to internal/webhook/kubernetes/projectconfig/webhook.go
diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/kubernetes/promotion/webhook.go
similarity index 99%
rename from internal/webhook/promotion/webhook.go
rename to internal/webhook/kubernetes/promotion/webhook.go
index 45a7016c..c9357ed1 100644
--- a/internal/webhook/promotion/webhook.go
+++ b/internal/webhook/kubernetes/promotion/webhook.go
@@ -25,7 +25,7 @@ import (
 	"github.com/akuity/kargo/internal/kargo"
 	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	"github.com/akuity/kargo/internal/logging"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 var (
diff --git a/internal/webhook/promotiontask/webhook.go b/internal/webhook/kubernetes/promotiontask/webhook.go
similarity index 94%
rename from internal/webhook/promotiontask/webhook.go
rename to internal/webhook/kubernetes/promotiontask/webhook.go
index 3b91a22e..91c0dd44 100644
--- a/internal/webhook/promotiontask/webhook.go
+++ b/internal/webhook/kubernetes/promotiontask/webhook.go
@@ -10,7 +10,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 var promotionTaskGroupKind = schema.GroupKind{
diff --git a/internal/webhook/stage/webhook.go b/internal/webhook/kubernetes/stage/webhook.go
similarity index 99%
rename from internal/webhook/stage/webhook.go
rename to internal/webhook/kubernetes/stage/webhook.go
index cb53d3c3..efe66f48 100644
--- a/internal/webhook/stage/webhook.go
+++ b/internal/webhook/kubernetes/stage/webhook.go
@@ -17,7 +17,7 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/api"
 	"github.com/akuity/kargo/internal/promotion"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 var (
diff --git a/internal/webhook/warehouse/webhook.go b/internal/webhook/kubernetes/warehouse/webhook.go
similarity index 99%
rename from internal/webhook/warehouse/webhook.go
rename to internal/webhook/kubernetes/warehouse/webhook.go
index 005b7e8c..013338a0 100644
--- a/internal/webhook/warehouse/webhook.go
+++ b/internal/webhook/kubernetes/warehouse/webhook.go
@@ -18,7 +18,7 @@ import (
 	"github.com/akuity/kargo/internal/git"
 	"github.com/akuity/kargo/internal/helm"
 	"github.com/akuity/kargo/internal/image"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 var warehouseGroupKind = schema.GroupKind{
diff --git a/ui/src/features/project/pipelines/freight/delete-freight-modal.tsx b/ui/src/features/project/pipelines/freight/delete-freight-modal.tsx
new file mode 100644
index 00000000..47cfdecb
--- /dev/null
+++ b/ui/src/features/project/pipelines/freight/delete-freight-modal.tsx
@@ -0,0 +1,55 @@
+import { useMutation } from '@connectrpc/connect-query';
+import { Alert, Modal, message } from 'antd';
+import { useParams } from 'react-router-dom';
+
+import { ModalComponentProps } from '@ui/features/common/modal/modal-context';
+import { getAlias } from '@ui/features/common/utils';
+import { deleteFreight } from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery';
+import { Freight } from '@ui/gen/api/v1alpha1/generated_pb';
+
+export const DeleteFreightModal = ({
+  visible,
+  hide,
+  onDelete,
+  freight
+}: ModalComponentProps & { onDelete: () => void; freight: Freight }) => {
+  const { name: project } = useParams();
+  const { mutate: deleteAction, isPending } = useMutation(deleteFreight, {
+    onSuccess: () => {
+      message.success('Freight successfully deleted');
+      onDelete();
+    }
+  });
+
+  const alias = getAlias(freight);
+
+  return (
+    <Modal
+      destroyOnClose
+      open={visible}
+      title='Confirm Delete'
+      onCancel={hide}
+      onOk={() =>
+        deleteAction({
+          name: freight.metadata?.name || '',
+          project
+        })
+      }
+      okText='Delete'
+      okButtonProps={{ loading: isPending, danger: true }}
+    >
+      <Alert
+        type='error'
+        banner
+        message={
+          <div>
+            Are you sure you want to delete freight{' '}
+            <span className='font-semibold'>{alias ? alias : freight?.metadata?.name}</span>?
+          </div>
+        }
+        className='mb-4'
+        showIcon={false}
+      />
+    </Modal>
+  );
+};
diff --git a/ui/src/features/project/pipelines/freight/freight-card.tsx b/ui/src/features/project/pipelines/freight/freight-card.tsx
index 1fc9dbd8..64c05d9f 100644
--- a/ui/src/features/project/pipelines/freight/freight-card.tsx
+++ b/ui/src/features/project/pipelines/freight/freight-card.tsx
@@ -1,8 +1,10 @@
 import { faDocker, faGithub } from '@fortawesome/free-brands-svg-icons';
 import {
   faAnchor,
+  faArrowsLeftRightToLine,
   faCheck,
   faEllipsis,
+  faTrash,
   faWarehouse,
   IconDefinition
 } from '@fortawesome/free-solid-svg-icons';
@@ -10,16 +12,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Button, Divider, Dropdown, Flex, Typography } from 'antd';
 import classNames from 'classnames';
 import { formatDistance } from 'date-fns';
-import { useMemo } from 'react';
+import { ReactNode, useMemo } from 'react';
 import { generatePath, useNavigate } from 'react-router-dom';
 
 import { paths } from '@ui/config/paths';
+import { useModal } from '@ui/features/common/modal/use-modal';
 import { useActionContext } from '@ui/features/project/pipelines/context/action-context';
 import { FreightTimelineControllerContextType } from '@ui/features/project/pipelines/context/freight-timeline-controller-context';
 import { ColorMap } from '@ui/features/stage/utils';
 import { Freight, Stage } from '@ui/gen/api/v1alpha1/generated_pb';
 import { timestampDate } from '@ui/utils/connectrpc-utils';
 
+import { DeleteFreightModal } from './delete-freight-modal';
 import { FreightArtifact } from './freight-artifact';
 
 type FreightCardProps = {
@@ -33,6 +37,10 @@ type FreightCardProps = {
   className?: string;
   promote?: boolean;
   onReviewAndPromote?(): void;
+  onExpand(): void;
+
+  // count of stacked freights
+  count?: number;
 };
 
 export const FreightCard = (props: FreightCardProps) => {
@@ -41,6 +49,8 @@ export const FreightCard = (props: FreightCardProps) => {
 
   const freightAlias = props.freight?.alias;
 
+  const deleteFreightModal = useModal();
+
   const creation = useMemo(() => {
     const creationDate = timestampDate(props.freight?.metadata?.creationTimestamp);
 
@@ -65,26 +75,22 @@ export const FreightCard = (props: FreightCardProps) => {
     props.viewingFreight?.metadata?.name === props.freight?.metadata?.name ||
     actionContext?.action?.freight?.metadata?.name === props.freight?.metadata?.name;
 
-  return (
-    <div
-      className={classNames(
-        'rounded-md text-center flex flex-col cursor-pointer hover:bg-gray-100',
-        {
-          'bg-gray-50': !isViewingFreight,
-          'bg-gray-100': isViewingFreight
-        },
-        props.className
-      )}
-      style={{ border: '1px solid rgba(0,0,0,.05)' }}
-      onClick={() => {
-        navigate(
-          generatePath(paths.freight, {
-            name: props.freight?.metadata?.namespace,
-            freightName: props.freight?.metadata?.name
-          })
-        );
-      }}
-    >
+  let CardContent: ReactNode;
+
+  if (props.count) {
+    CardContent = (
+      <div className='flex flex-col items-center justify-center h-full px-2 bg-gray-200'>
+        <Typography.Text type='secondary' className='text-xs'>
+          <FontAwesomeIcon icon={faArrowsLeftRightToLine} />
+          <br />
+          {props.count}x
+          <br />
+          freights
+        </Typography.Text>
+      </div>
+    );
+  } else {
+    CardContent = (
       <div
         className={classNames('relative px-2', {
           'pl-4': props.inUse
@@ -106,6 +112,22 @@ export const FreightCard = (props: FreightCardProps) => {
                     e.domEvent.stopPropagation();
                     actionContext?.actManuallyApprove(props.freight);
                   }
+                },
+                {
+                  key: 'delete-freight',
+                  label: 'Delete Freight',
+                  icon: <FontAwesomeIcon icon={faTrash} />,
+                  onClick: (e) => {
+                    e.domEvent.stopPropagation();
+                    deleteFreightModal.show((modalProps) => (
+                      <DeleteFreightModal
+                        freight={props.freight}
+                        onDelete={modalProps.hide}
+                        {...modalProps}
+                      />
+                    ));
+                  },
+                  disabled: props.inUse
                 }
               ]
             }}
@@ -189,6 +211,35 @@ export const FreightCard = (props: FreightCardProps) => {
           </Typography.Text>
         </div>
       </div>
+    );
+  }
+
+  return (
+    <div
+      className={classNames(
+        'rounded-md text-center flex flex-col cursor-pointer hover:bg-gray-100',
+        {
+          'bg-gray-50': !isViewingFreight,
+          'bg-gray-100': isViewingFreight
+        },
+        props.className
+      )}
+      style={{ border: '1px solid rgba(0,0,0,.05)' }}
+      onClick={() => {
+        if (props.count) {
+          props.onExpand();
+          return;
+        }
+
+        navigate(
+          generatePath(paths.freight, {
+            name: props.freight?.metadata?.namespace,
+            freightName: props.freight?.metadata?.name
+          })
+        );
+      }}
+    >
+      {CardContent}
 
       {props.promote && (
         <div className='px-1 pb-1'>
diff --git a/ui/src/features/project/pipelines/freight/freight-timeline-filters.tsx b/ui/src/features/project/pipelines/freight/freight-timeline-filters.tsx
index c6e1b118..06080b61 100644
--- a/ui/src/features/project/pipelines/freight/freight-timeline-filters.tsx
+++ b/ui/src/features/project/pipelines/freight/freight-timeline-filters.tsx
@@ -70,7 +70,7 @@ export const FreightTimelineFilters = (props: FreightTimelineFiltersProps) => {
 
       <div
         className={classNames('transition-all', {
-          'w-0 h-0 opacity-0': props.collapsed,
+          'w-0 h-0 opacity-0 invisible': props.collapsed,
           'w-full': !props.collapsed
         })}
       >
diff --git a/ui/src/features/project/pipelines/freight/freight-timeline.tsx b/ui/src/features/project/pipelines/freight/freight-timeline.tsx
index b7668def..2152206e 100644
--- a/ui/src/features/project/pipelines/freight/freight-timeline.tsx
+++ b/ui/src/features/project/pipelines/freight/freight-timeline.tsx
@@ -56,7 +56,9 @@ export const FreightTimeline = (props: { freights: Freight[]; project: string })
 
   const [viewingFreight, setViewingFreight] = useState<Freight | null>(null);
 
-  const filteredFreights = useMemo(() => {
+  const filteredFreights: (Freight & {
+    count?: number;
+  })[] = useMemo(() => {
     let filtered = props.freights?.sort((a, b) => {
       const t1 = timestampDate(a?.metadata?.creationTimestamp);
 
@@ -84,12 +86,32 @@ export const FreightTimeline = (props: { freights: Freight[]; project: string })
     }
 
     if (freightTimelineControllerContext.preferredFilter.hideUnusedFreights) {
-      filtered = filtered.filter((f) => {
+      const newFiltered: (Freight & {
+        count?: number;
+      })[] = [];
+
+      let count = 0;
+
+      for (const f of filtered) {
         const inUse =
           (dictionaryContext?.freightInStages[f?.metadata?.name || '']?.length || 0) > 0;
 
-        return inUse;
-      });
+        if (inUse) {
+          if (count > 0) {
+            newFiltered.push({
+              ...f,
+              count
+            });
+            count = 0;
+          }
+
+          newFiltered.push(f);
+        } else {
+          count++;
+        }
+      }
+
+      filtered = [...newFiltered];
     }
 
     return filtered;
@@ -177,7 +199,8 @@ export const FreightTimeline = (props: { freights: Freight[]; project: string })
 
               return (
                 <FreightCard
-                  key={freight?.metadata?.uid}
+                  key={`${freight?.metadata?.uid}-${freight?.count}`}
+                  count={freight?.count}
                   className='h-full'
                   stagesInFreight={
                     dictionaryContext?.freightInStages?.[freight?.metadata?.name || ''] || []
@@ -203,6 +226,12 @@ export const FreightTimeline = (props: { freights: Freight[]; project: string })
                       })
                     );
                   }}
+                  onExpand={() =>
+                    freightTimelineControllerContext?.setPreferredFilter({
+                      ...freightTimelineControllerContext?.preferredFilter,
+                      hideUnusedFreights: false
+                    })
+                  }
                 />
               );
             })}
diff --git a/ui/src/features/project/pipelines/graph/custom-node.tsx b/ui/src/features/project/pipelines/graph/custom-node.tsx
index ec4f9841..66f6f60e 100644
--- a/ui/src/features/project/pipelines/graph/custom-node.tsx
+++ b/ui/src/features/project/pipelines/graph/custom-node.tsx
@@ -114,7 +114,9 @@ CustomNode.Container = (
         type='target'
         position={Position.Left}
         style={{
-          backgroundColor: 'transparent'
+          backgroundColor: 'transparent',
+          stroke: 'none',
+          border: 'none'
         }}
       />
       {Children}
@@ -123,7 +125,9 @@ CustomNode.Container = (
         type='source'
         position={Position.Right}
         style={{
-          backgroundColor: 'transparent'
+          backgroundColor: 'transparent',
+          stroke: 'none',
+          border: 'none'
         }}
       />
     </>
diff --git a/ui/src/features/project/pipelines/nodes/stage-node.tsx b/ui/src/features/project/pipelines/nodes/stage-node.tsx
index 84d93875..52244a27 100644
--- a/ui/src/features/project/pipelines/nodes/stage-node.tsx
+++ b/ui/src/features/project/pipelines/nodes/stage-node.tsx
@@ -18,6 +18,7 @@ import { ColorContext } from '@ui/context/colors';
 import { HealthStatusIcon } from '@ui/features/common/health-status/health-status-icon';
 import { StageConditionIcon } from '@ui/features/common/stage-status/stage-condition-icon';
 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';
@@ -72,19 +73,26 @@ export const StageNode = (props: { stage: Stage }) => {
     const lastPromotion = getLastPromotionDate(props.stage);
     const date = timestampDate(lastPromotion) as Date;
 
-    let Phase = (
-      <Flex align='center' gap={4}>
-        {stagePhase}{' '}
-        <StageConditionIcon
-          className='text-[10px]'
-          conditions={props.stage?.status?.conditions || []}
-          noTooltip
-        />
-        {stagePhase === 'Promoting' && (
-          <FontAwesomeIcon icon={faExternalLink} className='text-[8px]' />
-        )}
-      </Flex>
-    );
+    let Phase = null;
+
+    // hiddeco & Marvin9: ignore phase for no freights
+    if (getCurrentFreight(props.stage).length > 0) {
+      Phase = (
+        <Flex align='center' gap={4}>
+          {stagePhase}{' '}
+          <StageConditionIcon
+            className='text-[10px]'
+            conditions={props.stage?.status?.conditions || []}
+            noTooltip
+          />
+          {stagePhase === 'Promoting' && (
+            <FontAwesomeIcon icon={faExternalLink} className='text-[8px]' />
+          )}
+        </Flex>
+      );
+    } else {
+      Phase = <Typography.Text type='secondary'>No freight</Typography.Text>;
+    }
 
     if (stagePhase === 'Promoting') {
       Phase = (
diff --git a/ui/src/features/project/pipelines/nodes/warehouse-node.tsx b/ui/src/features/project/pipelines/nodes/warehouse-node.tsx
index 4388cddc..fe002c60 100644
--- a/ui/src/features/project/pipelines/nodes/warehouse-node.tsx
+++ b/ui/src/features/project/pipelines/nodes/warehouse-node.tsx
@@ -9,10 +9,11 @@ import {
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { Badge, Button, Card, Flex, message } from 'antd';
 import classNames from 'classnames';
-import { useMemo } from 'react';
+import { useContext, useMemo } from 'react';
 import { generatePath, useNavigate } from 'react-router-dom';
 
 import { paths } from '@ui/config/paths';
+import { ColorContext } from '@ui/context/colors';
 import { useFreightTimelineControllerContext } from '@ui/features/project/pipelines/context/freight-timeline-controller-context';
 import { refreshWarehouse } from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery';
 import { Warehouse } from '@ui/gen/api/v1alpha1/generated_pb';
@@ -20,6 +21,7 @@ import { Warehouse } from '@ui/gen/api/v1alpha1/generated_pb';
 import styles from './node-size-source-of-truth.module.less';
 
 export const WarehouseNode = (props: { warehouse: Warehouse }) => {
+  const colorContext = useContext(ColorContext);
   const navigate = useNavigate();
 
   const freightTimelineControllerContext = useFreightTimelineControllerContext();
@@ -32,6 +34,8 @@ export const WarehouseNode = (props: { warehouse: Warehouse }) => {
     }
   });
 
+  const color = colorContext?.warehouseColorMap?.[props.warehouse?.metadata?.name || ''];
+
   const isSubscriptionHidden =
     freightTimelineControllerContext?.preferredFilter?.hideSubscriptions?.[
       props.warehouse?.metadata?.name || ''
@@ -67,6 +71,9 @@ export const WarehouseNode = (props: { warehouse: Warehouse }) => {
         </Flex>
       }
       className={(styles['warehouse-node-size'], 'relative')}
+      style={{
+        border: color && `1px solid ${color}`
+      }}
     >
       <Button
         size='small'
diff --git a/ui/src/features/stage/stage-details.tsx b/ui/src/features/stage/stage-details.tsx
index 0f277b6d..6b0ce167 100644
--- a/ui/src/features/stage/stage-details.tsx
+++ b/ui/src/features/stage/stage-details.tsx
@@ -27,6 +27,7 @@ import { decodeRawData } from '@ui/utils/decode-raw-data';
 
 import YamlEditor from '../common/code-editor/yaml-editor-lazy';
 import { StageConditionIcon } from '../common/stage-status/stage-condition-icon';
+import { getCurrentFreight } from '../common/utils';
 
 import { Promotions } from './promotions';
 import { RequestedFreight } from './requested-freight';
@@ -110,7 +111,9 @@ export const StageDetails = ({ stage }: { stage: Stage }) => {
                 {stage.metadata?.name}
               </Typography.Title>
               <Flex gap={4}>
-                <StageConditionIcon conditions={stageConditions} />
+                {getCurrentFreight(stage).length > 0 && (
+                  <StageConditionIcon conditions={stageConditions} />
+                )}
                 {!!stage.status?.health && <HealthStatusIcon health={stage.status?.health} />}
               </Flex>
             </Flex>



Test patch:
diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go
index 9d552681..d33a1481 100644
--- a/internal/indexer/indexer_test.go
+++ b/internal/indexer/indexer_test.go
@@ -818,3 +818,50 @@ func TestServiceAccountsByOIDCClaims(t *testing.T) {
 		})
 	}
 }
+
+func TestWarehousesByRepoURL(t *testing.T) {
+	for _, test := range []struct {
+		name      string
+		warehouse client.Object
+		expected  []string
+	}{
+		{
+			name: "simple",
+			warehouse: &kargoapi.Warehouse{
+				Spec: kargoapi.WarehouseSpec{
+					Subscriptions: []kargoapi.RepoSubscription{
+						{
+							Git: &kargoapi.GitSubscription{
+								RepoURL: "https://github.com/username/repo",
+							},
+							Image: &kargoapi.ImageSubscription{
+								RepoURL: "https://registry.hub.docker.com/u/svendowideit/testhook/",
+							},
+							Chart: &kargoapi.ChartSubscription{
+								RepoURL: "https://example.com/charts/alpine-0.1.2",
+							},
+						},
+					},
+				},
+			},
+			expected: []string{
+				"https://github.com/username/repo",
+				"https://example.com/charts/alpine-0.1.2",
+				"https://registry.hub.docker.com/u/svendowideit/testhook/",
+			},
+		},
+		{
+			name:      "not a warehouse",
+			warehouse: &kargoapi.Freight{},
+			expected:  nil,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			require.Equal(t,
+				test.expected,
+				WarehousesBySubscribedURLs(test.warehouse),
+			)
+
+		})
+	}
+}
diff --git a/internal/kargo/promotion_builder_test.go b/internal/kargo/promotion_builder_test.go
index 666c6be0..204e4459 100644
--- a/internal/kargo/promotion_builder_test.go
+++ b/internal/kargo/promotion_builder_test.go
@@ -560,8 +560,8 @@ func TestPromotionBuilder_inflateTaskSteps(t *testing.T) {
 				require.NoError(t, err)
 				require.Len(t, steps, 2)
 
-				assert.Equal(t, "custom-alias::step-0", steps[0].As)
-				assert.Equal(t, "custom-alias::step-1", steps[1].As)
+				assert.Equal(t, "custom-alias::step-1", steps[0].As)
+				assert.Equal(t, "custom-alias::step-2", steps[1].As)
 			},
 		},
 		{
diff --git a/internal/promotion/simple_engine_test.go b/internal/promotion/simple_engine_test.go
index a24ac865..8691ecfd 100644
--- a/internal/promotion/simple_engine_test.go
+++ b/internal/promotion/simple_engine_test.go
@@ -146,24 +146,6 @@ func TestSimpleEngine_executeSteps(t *testing.T) {
 		steps       []Step
 		assertions  func(*testing.T, Result)
 	}{
-		{
-			name: "fail on invalid step alias",
-			steps: []Step{
-				{Kind: "success-step", Alias: "step-1"},
-			},
-			assertions: func(t *testing.T, result Result) {
-				assert.Equal(t, kargoapi.PromotionPhaseErrored, result.Status)
-				assert.Contains(t, result.Message, "is forbidden")
-				assert.Equal(t, int64(0), result.CurrentStep)
-
-				assert.Len(t, result.StepExecutionMetadata, 1)
-
-				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.StepExecutionMetadata[0].Status)
-				assert.Contains(t, result.StepExecutionMetadata[0].Message, "is forbidden")
-				assert.Nil(t, result.StepExecutionMetadata[0].StartedAt)
-				assert.Nil(t, result.StepExecutionMetadata[0].FinishedAt)
-			},
-		},
 		{
 			name:  "runner not found",
 			steps: []Step{{Kind: "unknown-step"}},
@@ -767,7 +749,10 @@ func TestSimpleEngine_executeStep(t *testing.T) {
 		},
 		{
 			name: "step execution failure",
-			step: Step{Kind: "error-step"},
+			step: Step{
+				Kind:  "error-step",
+				Alias: "my-step",
+			},
 			runner: &mockStepRunner{
 				name: "error-step",
 				runResult: promotion.StepResult{
@@ -776,7 +761,7 @@ func TestSimpleEngine_executeStep(t *testing.T) {
 				runErr: errors.New("something went wrong"),
 			},
 			assertions: func(t *testing.T, result promotion.StepResult, err error) {
-				assert.ErrorContains(t, err, "error running step \"error-step\"")
+				assert.ErrorContains(t, err, "error running step \"my-step\"")
 				assert.ErrorContains(t, err, "something went wrong")
 				assert.Equal(t, kargoapi.PromotionStepStatusErrored, result.Status)
 			},
@@ -844,55 +829,6 @@ func TestSimpleEngine_prepareStepContext(t *testing.T) {
 	}
 }
 
-func TestSimpleEngine_stepAlias(t *testing.T) {
-	tests := []struct {
-		name       string
-		alias      string
-		index      int64
-		assertions func(*testing.T, string, error)
-	}{
-		{
-			name:  "use provided alias",
-			alias: "custom-step",
-			assertions: func(t *testing.T, alias string, err error) {
-				assert.NoError(t, err)
-				assert.Equal(t, "custom-step", alias)
-			},
-		},
-		{
-			name:  "generate default alias",
-			index: 42,
-			assertions: func(t *testing.T, alias string, err error) {
-				assert.NoError(t, err)
-				assert.Equal(t, "step-42", alias)
-			},
-		},
-		{
-			name:  "reject reserved alias",
-			alias: "step-1",
-			assertions: func(t *testing.T, _ string, err error) {
-				assert.ErrorContains(t, err, "forbidden")
-			},
-		},
-		{
-			name:  "trim whitespace",
-			alias: "  step  ",
-			assertions: func(t *testing.T, alias string, err error) {
-				assert.NoError(t, err)
-				assert.Equal(t, "step", alias)
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			engine := &simpleEngine{}
-			alias, err := engine.stepAlias(tt.alias, tt.index)
-			tt.assertions(t, alias, err)
-		})
-	}
-}
-
 func TestSimpleEngine_setupWorkDir(t *testing.T) {
 	tmpDir := t.TempDir()
 
diff --git a/internal/webhook/external/server_test.go b/internal/webhook/external/server_test.go
new file mode 100644
index 00000000..a023f05a
--- /dev/null
+++ b/internal/webhook/external/server_test.go
@@ -0,0 +1,36 @@
+package external
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/akuity/kargo/internal/server/kubernetes"
+)
+
+func TestNewServer(t *testing.T) {
+	testServerConfig := ServerConfig{}
+	testClient, err := kubernetes.NewClient(
+		context.Background(),
+		&rest.Config{},
+		kubernetes.ClientOptions{
+			NewInternalClient: func(
+				context.Context,
+				*rest.Config,
+				*runtime.Scheme,
+			) (client.Client, error) {
+				return fake.NewClientBuilder().Build(), nil
+			},
+		},
+	)
+	require.NoError(t, err)
+
+	s, ok := NewServer(testServerConfig, testClient).(*server)
+	require.True(t, ok)
+	require.NotNil(t, s)
+}
diff --git a/internal/webhook/admission_test.go b/internal/webhook/kubernetes/admission_test.go
similarity index 100%
rename from internal/webhook/admission_test.go
rename to internal/webhook/kubernetes/admission_test.go
diff --git a/internal/webhook/config_test.go b/internal/webhook/kubernetes/config_test.go
similarity index 100%
rename from internal/webhook/config_test.go
rename to internal/webhook/kubernetes/config_test.go
diff --git a/internal/webhook/freight/webhook_test.go b/internal/webhook/kubernetes/freight/webhook_test.go
similarity index 99%
rename from internal/webhook/freight/webhook_test.go
rename to internal/webhook/kubernetes/freight/webhook_test.go
index f8146c49..991b6126 100644
--- a/internal/webhook/freight/webhook_test.go
+++ b/internal/webhook/kubernetes/freight/webhook_test.go
@@ -25,7 +25,7 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/api"
 	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 func TestNewWebhook(t *testing.T) {
diff --git a/internal/webhook/project/webhook_test.go b/internal/webhook/kubernetes/project/webhook_test.go
similarity index 100%
rename from internal/webhook/project/webhook_test.go
rename to internal/webhook/kubernetes/project/webhook_test.go
diff --git a/internal/webhook/projectconfig/webhook_test.go b/internal/webhook/kubernetes/projectconfig/webhook_test.go
similarity index 100%
rename from internal/webhook/projectconfig/webhook_test.go
rename to internal/webhook/kubernetes/projectconfig/webhook_test.go
diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/kubernetes/promotion/webhook_test.go
similarity index 99%
rename from internal/webhook/promotion/webhook_test.go
rename to internal/webhook/kubernetes/promotion/webhook_test.go
index d4d9223a..528cfa1b 100644
--- a/internal/webhook/promotion/webhook_test.go
+++ b/internal/webhook/kubernetes/promotion/webhook_test.go
@@ -23,7 +23,7 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/api"
 	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 func TestNewWebhook(t *testing.T) {
diff --git a/internal/webhook/promotiontask/webhook_test.go b/internal/webhook/kubernetes/promotiontask/webhook_test.go
similarity index 100%
rename from internal/webhook/promotiontask/webhook_test.go
rename to internal/webhook/kubernetes/promotiontask/webhook_test.go
diff --git a/internal/webhook/stage/webhook_test.go b/internal/webhook/kubernetes/stage/webhook_test.go
similarity index 99%
rename from internal/webhook/stage/webhook_test.go
rename to internal/webhook/kubernetes/stage/webhook_test.go
index 51931ca4..ade79908 100644
--- a/internal/webhook/stage/webhook_test.go
+++ b/internal/webhook/kubernetes/stage/webhook_test.go
@@ -19,7 +19,7 @@ import (
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/api"
-	libWebhook "github.com/akuity/kargo/internal/webhook"
+	libWebhook "github.com/akuity/kargo/internal/webhook/kubernetes"
 )
 
 func TestNewWebhook(t *testing.T) {
diff --git a/internal/webhook/warehouse/webhook_test.go b/internal/webhook/kubernetes/warehouse/webhook_test.go
similarity index 100%
rename from internal/webhook/warehouse/webhook_test.go
rename to internal/webhook/kubernetes/warehouse/webhook_test.go