Repository: akuity/kargo
Pull request #1739 (2024-04-02 21:25:59)

feat: emit Kubernetes events on Kargo events
> Part of https://github.com/akuity/kargo/issues/1231

This PR let kargo components (API or controllers) to emit Kubernetes events when some notable events are happened.

For now, it emits on following Kargo events:

- User created Promotion
- User approved a Freight for Promotion
- Freight Verification Completed in a Stage
- Promotion Completed (Succeded/Failed/Errored)

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-akuity-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 96372b57e951f07f28b278fd02a7da4b3ccb5d1d |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/660c77e96b78490008b76567 |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1739?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
Attention: Patch coverage is `64.93506%` with `27 lines` in your changes are missing coverage. Please review.
> Project coverage is 44.29%. Comparing base [(`0c4cd6a`)](https://app.codecov.io/gh/akuity/kargo/commit/0c4cd6aec57902c66591aaa9f0d63f5da35e46df?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`5b75523`)](https://app.codecov.io/gh/akuity/kargo/pull/1739?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/akuity/kargo/pull/1739?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [cmd/controlplane/api.go](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&filepath=cmd%2Fcontrolplane%2Fapi.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-Y21kL2NvbnRyb2xwbGFuZS9hcGkuZ28=) | 0.00% | [10 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/kubeclient/fake/event.go](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&filepath=internal%2Fkubeclient%2Ffake%2Fevent.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwva3ViZWNsaWVudC9mYWtlL2V2ZW50Lmdv) | 68.42% | [6 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/api/approve\_freight\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&filepath=internal%2Fapi%2Fapprove_freight_v1alpha1.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL2FwcHJvdmVfZnJlaWdodF92MWFscGhhMS5nbw==) | 72.22% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/api/promote\_to\_stage\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&filepath=internal%2Fapi%2Fpromote_to_stage_v1alpha1.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL3Byb21vdGVfdG9fc3RhZ2VfdjFhbHBoYTEuZ28=) | 64.28% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/cli/cmd/server/server.go](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&filepath=internal%2Fcli%2Fcmd%2Fserver%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY2xpL2NtZC9zZXJ2ZXIvc2VydmVyLmdv) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1739?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1739      +/-   ##
==========================================
+ Coverage   44.13%   44.29%   +0.15%     
==========================================
  Files         207      208       +1     
  Lines       13105    13172      +67     
==========================================
+ Hits         5784     5834      +50     
- Misses       7078     7093      +15     
- Partials      243      245       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1739?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).

krancour: I made a few comments. Overall, this looks good.

One thing I notice is missing here is that if a user created a promotion via `kubectl`, there will be no event published for that.

I understand why this is being done in the API server -- because there is a better ability to capture user identity there -- but I don't think I'd want to completely ignore Promotions created via `kubectl`.

Here is what I propose:

A webhook can access Kubernetes end-user information, like user or SA name. Modify the existing validating webhook for Promotions so that on create, _if valid_, and _not_ created by the API server or controller, the Promotion created event is published from there. If the API server is the user creating the Promotion, you can ignore, with the assumption that the API server did or will publish its own event with information about the OIDC user. Similarly, if the controller is the user creating the Promotion (which only happens on auto-promotion), you can assume that the controller did or will publish its own event.

krancour: Apart from [one nit](https://github.com/akuity/kargo/pull/1739#discussion_r1558184530), I am happy with this. Nice work, @hoon. 🔥 

I'll wait to see what @gdsoumya says [this](about https://github.com/akuity/kargo/pull/1739#discussion_r1558154822) before approving.

Patch:
diff --git a/api/v1alpha1/annotations.go b/api/v1alpha1/annotations.go
index 5e3150fa..6b097d15 100644
--- a/api/v1alpha1/annotations.go
+++ b/api/v1alpha1/annotations.go
@@ -9,4 +9,13 @@ const (
 	AnnotationKeyOIDCEmails   = "rbac.kargo.akuity.io/email"
 	AnnotationKeyOIDCGroups   = "rbac.kargo.akuity.io/groups"
 	AnnotationKeyOIDCSubjects = "rbac.kargo.akuity.io/sub"
+
+	AnnotationKeyEventActor               = "event.kargo.akuity.io/actor"
+	AnnotationKeyEventProject             = "event.kargo.akuity.io/project"
+	AnnotationKeyEventPromotionName       = "event.kargo.akuity.io/promotion-name"
+	AnnotationKeyEventPromotionCreateTime = "event.kargo.akuity.io/promotion-create-time"
+	AnnotationKeyEventFreightAlias        = "event.kargo.akuity.io/freight-alias"
+	AnnotationKeyEventFreightName         = "event.kargo.akuity.io/freight-name"
+	AnnotationKeyEventStageName           = "event.kargo.akuity.io/stage-name"
+	AnnotationKeyEventAnalysisRunName     = "event.kargo.akuity.io/analysis-run-name"
 )
diff --git a/api/v1alpha1/event.go b/api/v1alpha1/event.go
new file mode 100644
index 00000000..06a7b61a
--- /dev/null
+++ b/api/v1alpha1/event.go
@@ -0,0 +1,88 @@
+package v1alpha1
+
+import (
+	authnv1 "k8s.io/api/authentication/v1"
+
+	"github.com/akuity/kargo/internal/api/user"
+)
+
+const (
+	EventReasonPromotionCreated                = "PromotionCreated"
+	EventReasonPromotionSucceeded              = "PromotionSucceeded"
+	EventReasonPromotionFailed                 = "PromotionFailed"
+	EventReasonPromotionErrored                = "PromotionErrored"
+	EventReasonFreightApproved                 = "FreightApproved"
+	EventReasonFreightVerificationSucceeded    = "FreightVerificationSucceeded"
+	EventReasonFreightVerificationFailed       = "FreightVerificationFailed"
+	EventReasonFreightVerificationErrored      = "FreightVerificationErrored"
+	EventReasonFreightVerificationAborted      = "FreightVerificationAborted"
+	EventReasonFreightVerificationInconclusive = "FreightVerificationInconclusive"
+	EventReasonFreightVerificationUnknown      = "FreightVerificationUnknown"
+)
+
+const (
+	EventActorAdmin                = "admin"
+	EventActorControllerPrefix     = "controller:"
+	EventActorEmailPrefix          = "email:"
+	EventActorSubjectPrefix        = "subject:"
+	EventActorKubernetesUserPrefix = "kubernetes:"
+	EventActorUnknown              = "unknown actor"
+)
+
+func FormatEventControllerActor(name string) string {
+	return EventActorControllerPrefix + name
+}
+
+// FormatEventUserActor returns a string representation of the user acting in an event
+// that can be used as a value of AnnotationKeyEventActor.
+//
+// 1. If the user is admin, it returns EventActorAdmin since other information is not available.
+// 2. If the email is available, it returns email in "email:<email>" format.
+// 3. If the subject is available, it returns subject in "subject:<subject>" format.
+// 4. Otherwise, it returns EventActorUnknown.
+func FormatEventUserActor(u user.Info) string {
+	switch {
+	case u.IsAdmin:
+		return EventActorAdmin
+	case u.Email != "":
+		return EventActorEmailPrefix + u.Email
+	case u.Subject != "":
+		return EventActorSubjectPrefix + u.Subject
+	default:
+		return EventActorUnknown
+	}
+}
+
+func FormatEventKubernetesUserActor(u authnv1.UserInfo) string {
+	return EventActorKubernetesUserPrefix + u.Username
+}
+
+func NewFreightApprovedEventAnnotations(actor string, f *Freight, stageName string) map[string]string {
+	annotations := map[string]string{
+		AnnotationKeyEventProject:      f.Namespace,
+		AnnotationKeyEventFreightAlias: f.Alias,
+		AnnotationKeyEventFreightName:  f.Name,
+		AnnotationKeyEventStageName:    stageName,
+	}
+	if actor != "" {
+		annotations[AnnotationKeyEventActor] = actor
+	}
+	return annotations
+}
+
+func NewPromotionCreatedEventAnnotations(actor string, p *Promotion, f *Freight) map[string]string {
+	annotations := map[string]string{
+		AnnotationKeyEventActor:         actor,
+		AnnotationKeyEventProject:       p.Namespace,
+		AnnotationKeyEventPromotionName: p.Name,
+		AnnotationKeyEventFreightName:   p.Spec.Freight,
+		AnnotationKeyEventStageName:     p.Spec.Stage,
+	}
+	if actor != "" {
+		annotations[AnnotationKeyEventActor] = actor
+	}
+	if f != nil {
+		annotations[AnnotationKeyEventFreightAlias] = f.Alias
+	}
+	return annotations
+}
diff --git a/charts/kargo/README.md b/charts/kargo/README.md
index 3970fbb3..2a2b5072 100644
--- a/charts/kargo/README.md
+++ b/charts/kargo/README.md
@@ -158,18 +158,19 @@ the Kargo controller is running.
 
 ### Webhooks Server
 
-| Name                                | Description                                                                                                                                                                                                                                                                                                                                                                           | Value  |
-| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
-| `webhooksServer.enabled`            | Whether the webhooks server is enabled.                                                                                                                                                                                                                                                                                                                                               | `true` |
-| `webhooksServer.replicas`           | The number of webhooks server pods.                                                                                                                                                                                                                                                                                                                                                   | `1`    |
-| `webhooksServer.logLevel`           | The log level for the webhooks server.                                                                                                                                                                                                                                                                                                                                                | `INFO` |
-| `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.                                                                                                                                                                                                                                                                                                                                           | `{}`   |
-| `webhooksServer.tolerations`        | Tolerations for the webhooks server pods.                                                                                                                                                                                                                                                                                                                                             | `[]`   |
-| `webhooksServer.affinity`           | Specifies pod affinity for the webhooks server pods.                                                                                                                                                                                                                                                                                                                                  | `{}`   |
-| `webhooksServer.annotations`        | Annotations to add to the webhooks server pods.                                                                                                                                                                                                                                                                                                                                       | `{}`   |
-| `webhooksServer.securityContext`    | Security context for webhooks server pods.                                                                                                                                                                                                                                                                                                                                            | `{}`   |
+| Name                                   | Description                                                                                                                                                                                                                                                                                                                                                                           | Value  |
+| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
+| `webhooksServer.enabled`               | Whether the webhooks server is enabled.                                                                                                                                                                                                                                                                                                                                               | `true` |
+| `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.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.                                                                                                                                                                                                                                                                                                                                           | `{}`   |
+| `webhooksServer.tolerations`           | Tolerations for the webhooks server pods.                                                                                                                                                                                                                                                                                                                                             | `[]`   |
+| `webhooksServer.affinity`              | Specifies pod affinity for the webhooks server pods.                                                                                                                                                                                                                                                                                                                                  | `{}`   |
+| `webhooksServer.annotations`           | Annotations to add to the webhooks server pods.                                                                                                                                                                                                                                                                                                                                       | `{}`   |
+| `webhooksServer.securityContext`       | Security context for webhooks server pods.                                                                                                                                                                                                                                                                                                                                            | `{}`   |
 
 ### Garbage Collector
 
diff --git a/charts/kargo/templates/_helpers.tpl b/charts/kargo/templates/_helpers.tpl
index e18aa92a..6edad6eb 100644
--- a/charts/kargo/templates/_helpers.tpl
+++ b/charts/kargo/templates/_helpers.tpl
@@ -22,6 +22,28 @@ Create chart name and version as used by the chart label.
 {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
 {{- end -}}
 
+{{/*
+Create default controlplane user regular expression with well-known service accounts
+*/}}
+{{- define "kargo.controlplane.defaultUserRegex" -}}
+{{- $list := list }}
+{{- if .Values.api.enabled }}
+{{- $list = append $list "kargo-api" }}
+{{- end }}
+{{- if .Values.controller.enabled }}
+{{- $list = append $list "kargo-controller" }}
+{{- end }}
+{{- if .Values.garbageCollector.enabled }}
+{{- $list = append $list "kargo-garbage-collector" }}
+{{- end }}
+{{- if .Values.managementController.enabled }}
+{{- $list = append $list "kargo-management-controller" }}
+{{- end }}
+{{- if $list }}
+{{- printf "^system:serviceaccount:%s:(%s)$" .Release.Namespace (join "|" $list) }}
+{{- end }}
+{{- end }}
+
 {{/*
 Common labels
 */}}
diff --git a/charts/kargo/templates/api/cluster-role.yaml b/charts/kargo/templates/api/cluster-role.yaml
index 3ae28df2..0bc21dfe 100644
--- a/charts/kargo/templates/api/cluster-role.yaml
+++ b/charts/kargo/templates/api/cluster-role.yaml
@@ -23,6 +23,13 @@ rules:
       - get
       - list
       - watch
+  - apiGroups:
+      - ""
+    resources:
+      - events
+    verbs:
+      - create
+      - patch
   - apiGroups:
       - authorization.k8s.io
     resources:
diff --git a/charts/kargo/templates/controller/cluster-roles.yaml b/charts/kargo/templates/controller/cluster-roles.yaml
index d48d9fb0..997ab512 100644
--- a/charts/kargo/templates/controller/cluster-roles.yaml
+++ b/charts/kargo/templates/controller/cluster-roles.yaml
@@ -7,6 +7,13 @@ metadata:
     {{- include "kargo.labels" . | nindent 4 }}
     {{- include "kargo.controller.labels" . | nindent 4 }}
 rules:
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
 - apiGroups:
   - ""
   resources:
diff --git a/charts/kargo/templates/webhooks-server/cluster-role.yaml b/charts/kargo/templates/webhooks-server/cluster-role.yaml
index f465a512..e0c1e756 100644
--- a/charts/kargo/templates/webhooks-server/cluster-role.yaml
+++ b/charts/kargo/templates/webhooks-server/cluster-role.yaml
@@ -17,6 +17,13 @@ rules:
   - list
   - update
   - watch
+- apiGroups:
+    - ""
+  resources:
+    - events
+  verbs:
+    - create
+    - patch
 - apiGroups:
   - ""
   resources:
diff --git a/charts/kargo/templates/webhooks-server/configmap.yaml b/charts/kargo/templates/webhooks-server/configmap.yaml
index ebd40931..dc33f77b 100644
--- a/charts/kargo/templates/webhooks-server/configmap.yaml
+++ b/charts/kargo/templates/webhooks-server/configmap.yaml
@@ -13,4 +13,9 @@ data:
   {{- if .Values.kubeconfigSecrets.kargo }}
   KUBECONFIG: /etc/kargo/kubeconfigs/kubeconfig.yaml
   {{- end }}
+  {{- if .Values.webhooksServer.controlplaneUserRegex }}
+  CONTROLPLANE_USER_REGEX: {{ .Values.webhooksServer.controlplaneUserRegex }}
+  {{- else }}
+  CONTROLPLANE_USER_REGEX: {{ include "kargo.controlplane.defaultUserRegex" . }}
+  {{- end }}
 {{- end }}
diff --git a/charts/kargo/templates/webhooks/webhooks.yaml b/charts/kargo/templates/webhooks/webhooks.yaml
index 8488ce60..47200022 100644
--- a/charts/kargo/templates/webhooks/webhooks.yaml
+++ b/charts/kargo/templates/webhooks/webhooks.yaml
@@ -92,7 +92,7 @@ webhooks:
   - scope: Namespaced
     apiGroups: ["kargo.akuity.io"]
     apiVersions: ["v1alpha1"]
-    resources: ["freights"]
+    resources: ["freights", "freights/status"]
     operations: ["CREATE", "UPDATE", "DELETE"]
   failurePolicy: Fail
 - name: project.kargo.akuity.io
diff --git a/charts/kargo/values.yaml b/charts/kargo/values.yaml
index 1b24862e..e42a20c2 100755
--- a/charts/kargo/values.yaml
+++ b/charts/kargo/values.yaml
@@ -354,6 +354,8 @@ webhooksServer:
   replicas: 1
   ## @param webhooksServer.logLevel The log level for the webhooks server.
   logLevel: INFO
+  ## @param webhooksServer.controlplaneUserRegex Regular expression for matching controlplane users.
+  controlplaneUserRegex: "" # ^system:serviceaccount:kargo:[a-z0-9]([-a-z0-9]*[a-z0-9])?$
 
   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.
diff --git a/cmd/controlplane/api.go b/cmd/controlplane/api.go
index 08de30f6..ee7d8552 100644
--- a/cmd/controlplane/api.go
+++ b/cmd/controlplane/api.go
@@ -11,6 +11,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	kubescheme "k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
@@ -70,7 +71,7 @@ func (o *apiOptions) run(ctx context.Context) error {
 
 	cfg := config.ServerConfigFromEnv()
 
-	clientCfg, internalClient, err := o.setupAPIClient(ctx)
+	clientCfg, internalClient, recorder, err := o.setupAPIClient(ctx)
 	if err != nil {
 		return fmt.Errorf("error setting up internal Kubernetes API client: %w", err)
 	}
@@ -103,7 +104,7 @@ func (o *apiOptions) run(ctx context.Context) error {
 		}).Info("SSO via OpenID Connect is enabled")
 	}
 
-	srv := api.NewServer(cfg, kubeClient, internalClient)
+	srv := api.NewServer(cfg, kubeClient, internalClient, recorder)
 	l, err := net.Listen("tcp", fmt.Sprintf("%s:%s", o.Host, o.Port))
 	if err != nil {
 		return fmt.Errorf("error creating listener: %w", err)
@@ -116,23 +117,23 @@ func (o *apiOptions) run(ctx context.Context) error {
 	return nil
 }
 
-func (o *apiOptions) setupAPIClient(ctx context.Context) (*rest.Config, client.Client, error) {
+func (o *apiOptions) setupAPIClient(ctx context.Context) (*rest.Config, client.Client, record.EventRecorder, error) {
 	restCfg, err := kubernetes.GetRestConfig(ctx, o.KubeConfig)
 	if err != nil {
-		return nil, nil, fmt.Errorf("get REST config: %w", err)
+		return nil, nil, nil, fmt.Errorf("get REST config: %w", err)
 	}
 
 	scheme := runtime.NewScheme()
 	if err = kubescheme.AddToScheme(scheme); err != nil {
-		return nil, nil, fmt.Errorf("error adding Kubernetes API to Kargo API manager scheme: %w", err)
+		return nil, nil, nil, fmt.Errorf("error adding Kubernetes API to Kargo API manager scheme: %w", err)
 	}
 
 	if err = rollouts.AddToScheme(scheme); err != nil {
-		return nil, nil, fmt.Errorf("error adding Argo Rollouts API to Kargo API manager scheme: %w", err)
+		return nil, nil, nil, fmt.Errorf("error adding Argo Rollouts API to Kargo API manager scheme: %w", err)
 	}
 
 	if err = kargoapi.AddToScheme(scheme); err != nil {
-		return nil, nil, fmt.Errorf("error adding Kargo API to Kargo API manager scheme: %w", err)
+		return nil, nil, nil, fmt.Errorf("error adding Kargo API to Kargo API manager scheme: %w", err)
 	}
 
 	mgr, err := ctrl.NewManager(restCfg, ctrl.Options{
@@ -149,11 +150,11 @@ func (o *apiOptions) setupAPIClient(ctx context.Context) (*rest.Config, client.C
 		},
 	})
 	if err != nil {
-		return nil, nil, fmt.Errorf("error initializing Kargo API manager: %w", err)
+		return nil, nil, nil, fmt.Errorf("error initializing Kargo API manager: %w", err)
 	}
 
 	if err = registerKargoIndexers(ctx, mgr); err != nil {
-		return nil, nil, fmt.Errorf("failed to register Kargo indexers: %w", err)
+		return nil, nil, nil, fmt.Errorf("failed to register Kargo indexers: %w", err)
 	}
 
 	go func() {
@@ -162,7 +163,7 @@ func (o *apiOptions) setupAPIClient(ctx context.Context) (*rest.Config, client.C
 		}
 	}()
 
-	return restCfg, mgr.GetClient(), nil
+	return restCfg, mgr.GetClient(), mgr.GetEventRecorderFor("api"), nil
 }
 
 func registerKargoIndexers(ctx context.Context, mgr ctrl.Manager) error {
diff --git a/cmd/controlplane/controller.go b/cmd/controlplane/controller.go
index e5b51f43..4d2d9485 100644
--- a/cmd/controlplane/controller.go
+++ b/cmd/controlplane/controller.go
@@ -84,6 +84,7 @@ func (o *controllerOptions) run(ctx context.Context) error {
 	}
 	startupLogEntry.Info("Starting Kargo Controller")
 
+	promotionsReconcilerCfg := promotions.ReconcilerConfigFromEnv()
 	stagesReconcilerCfg := stages.ReconcilerConfigFromEnv()
 
 	kargoMgr, err := o.setupKargoManager(ctx, stagesReconcilerCfg)
@@ -101,7 +102,14 @@ func (o *controllerOptions) run(ctx context.Context) error {
 		credentials.KubernetesDatabaseConfigFromEnv(),
 	)
 
-	if err := o.setupReconcilers(ctx, kargoMgr, argocdMgr, credentialsDB, stagesReconcilerCfg); err != nil {
+	if err := o.setupReconcilers(
+		ctx,
+		kargoMgr,
+		argocdMgr,
+		credentialsDB,
+		promotionsReconcilerCfg,
+		stagesReconcilerCfg,
+	); err != nil {
 		return fmt.Errorf("error setting up reconcilers: %w", err)
 	}
 
@@ -254,6 +262,7 @@ func (o *controllerOptions) setupReconcilers(
 	ctx context.Context,
 	kargoMgr, argocdMgr manager.Manager,
 	credentialsDB credentials.Database,
+	promotionsReconcilerCfg promotions.ReconcilerConfig,
 	stagesReconcilerCfg stages.ReconcilerConfig,
 ) error {
 	if err := promotions.SetupReconcilerWithManager(
@@ -261,7 +270,7 @@ func (o *controllerOptions) setupReconcilers(
 		kargoMgr,
 		argocdMgr,
 		credentialsDB,
-		o.ShardName,
+		promotionsReconcilerCfg,
 	); err != nil {
 		return fmt.Errorf("error setting up Promotions reconciler: %w", err)
 	}
diff --git a/cmd/controlplane/webhooks.go b/cmd/controlplane/webhooks.go
index 206ee52f..a07c8b11 100644
--- a/cmd/controlplane/webhooks.go
+++ b/cmd/controlplane/webhooks.go
@@ -19,6 +19,7 @@ import (
 	"github.com/akuity/kargo/internal/kubeclient"
 	"github.com/akuity/kargo/internal/os"
 	versionpkg "github.com/akuity/kargo/internal/version"
+	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/promotion"
@@ -63,6 +64,8 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 		"commit":  version.GitCommit,
 	}).Info("Starting Kargo Webhooks Server")
 
+	webhookCfg := libWebhook.ConfigFromEnv()
+
 	restCfg, err := kubernetes.GetRestConfig(ctx, o.KubeConfig)
 	if err != nil {
 		return fmt.Errorf("error getting REST config: %w", err)
@@ -105,7 +108,7 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 		return fmt.Errorf("index Stages by Freight: %w", err)
 	}
 
-	if err = freight.SetupWebhookWithManager(mgr); err != nil {
+	if err = freight.SetupWebhookWithManager(webhookCfg, mgr); err != nil {
 		return fmt.Errorf("setup Freight webhook: %w", err)
 	}
 	if err = project.SetupWebhookWithManager(
@@ -114,7 +117,7 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 	); err != nil {
 		return fmt.Errorf("setup Project webhook: %w", err)
 	}
-	if err = promotion.SetupWebhookWithManager(mgr); err != nil {
+	if err = promotion.SetupWebhookWithManager(webhookCfg, mgr); err != nil {
 		return fmt.Errorf("setup Promotion webhook: %w", err)
 	}
 	if err = stage.SetupWebhookWithManager(mgr); err != nil {
diff --git a/go.mod b/go.mod
index 462f60b9..ad56e8f4 100644
--- a/go.mod
+++ b/go.mod
@@ -44,6 +44,7 @@ require (
 	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/api v0.29.3
 	k8s.io/apimachinery v0.29.3
+	k8s.io/apiserver v0.29.3
 	k8s.io/cli-runtime v0.29.3
 	k8s.io/client-go v0.29.3
 	k8s.io/code-generator v0.29.3
diff --git a/go.sum b/go.sum
index a703306b..4a50745f 100644
--- a/go.sum
+++ b/go.sum
@@ -513,6 +513,8 @@ k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3
 k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc=
 k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU=
 k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
+k8s.io/apiserver v0.29.3 h1:xR7ELlJ/BZSr2n4CnD3lfA4gzFivh0wwfNfz9L0WZcE=
+k8s.io/apiserver v0.29.3/go.mod h1:hrvXlwfRulbMbBgmWRQlFru2b/JySDpmzvQwwk4GUOs=
 k8s.io/cli-runtime v0.29.3 h1:r68rephmmytoywkw2MyJ+CxjpasJDQY7AGc3XY2iv1k=
 k8s.io/cli-runtime v0.29.3/go.mod h1:aqVUsk86/RhaGJwDhHXH0jcdqBrgdF3bZWk4Z9D4mkM=
 k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg=
diff --git a/internal/api/approve_freight_v1alpha1.go b/internal/api/approve_freight_v1alpha1.go
index c89c2ef4..6a23a126 100644
--- a/internal/api/approve_freight_v1alpha1.go
+++ b/internal/api/approve_freight_v1alpha1.go
@@ -6,11 +6,13 @@ import (
 	"fmt"
 
 	"connectrpc.com/connect"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/user"
 	"github.com/akuity/kargo/internal/kubeclient"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
@@ -112,6 +114,20 @@ func (s *server) ApproveFreight(
 		return nil, fmt.Errorf("patch status: %w", err)
 	}
 
+	var actor string
+	eventMsg := fmt.Sprintf("Freight approved for Stage %q", stageName)
+	if u, ok := user.InfoFromContext(ctx); ok {
+		actor = kargoapi.FormatEventUserActor(u)
+		eventMsg += fmt.Sprintf(" by %q", actor)
+	}
+
+	s.recorder.AnnotatedEventf(
+		freight,
+		kargoapi.NewFreightApprovedEventAnnotations(actor, freight, stageName),
+		corev1.EventTypeNormal,
+		kargoapi.EventReasonFreightApproved,
+		eventMsg,
+	)
 	return &connect.Response[svcv1alpha1.ApproveFreightResponse]{}, nil
 }
 
diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1.go b/internal/api/promote_to_stage_subscribers_v1alpha1.go
index f6d3e8f8..add4d122 100644
--- a/internal/api/promote_to_stage_subscribers_v1alpha1.go
+++ b/internal/api/promote_to_stage_subscribers_v1alpha1.go
@@ -138,6 +138,7 @@ func (s *server) PromoteToStageSubscribers(
 			promoteErrs = append(promoteErrs, err)
 			continue
 		}
+		s.recordPromotionCreatedEvent(ctx, &newPromo, freight)
 		createdPromos = append(createdPromos, &newPromo)
 	}
 
diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go
index 2542d6cd..62f27c76 100644
--- a/internal/api/promote_to_stage_v1alpha1.go
+++ b/internal/api/promote_to_stage_v1alpha1.go
@@ -6,10 +6,12 @@ import (
 	"fmt"
 
 	"connectrpc.com/connect"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/user"
 	"github.com/akuity/kargo/internal/kargo"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
@@ -120,7 +122,28 @@ func (s *server) PromoteToStage(
 	if err := s.createPromotionFn(ctx, &promotion); err != nil {
 		return nil, fmt.Errorf("create promotion: %w", err)
 	}
+	s.recordPromotionCreatedEvent(ctx, &promotion, freight)
 	return connect.NewResponse(&svcv1alpha1.PromoteToStageResponse{
 		Promotion: &promotion,
 	}), nil
 }
+
+func (s *server) recordPromotionCreatedEvent(
+	ctx context.Context,
+	p *kargoapi.Promotion,
+	f *kargoapi.Freight,
+) {
+	var actor string
+	msg := fmt.Sprintf("Promotion created for Stage %q", p.Spec.Stage)
+	if u, ok := user.InfoFromContext(ctx); ok {
+		actor = kargoapi.FormatEventUserActor(u)
+		msg += fmt.Sprintf(" by %q", actor)
+	}
+	s.recorder.AnnotatedEventf(
+		p,
+		kargoapi.NewPromotionCreatedEventAnnotations(actor, p, f),
+		corev1.EventTypeNormal,
+		kargoapi.EventReasonPromotionCreated,
+		msg,
+	)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 13e707a1..4f246142 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -16,6 +16,7 @@ import (
 	"golang.org/x/net/http2/h2c"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
@@ -38,6 +39,7 @@ type server struct {
 	cfg            config.ServerConfig
 	client         kubernetes.Client
 	internalClient client.Client
+	recorder       record.EventRecorder
 
 	// The following behaviors are overridable for testing purposes:
 
@@ -150,11 +152,13 @@ func NewServer(
 	cfg config.ServerConfig,
 	kubeClient kubernetes.Client,
 	internalClient client.Client,
+	recorder record.EventRecorder,
 ) Server {
 	s := &server{
 		cfg:            cfg,
 		client:         kubeClient,
 		internalClient: internalClient,
+		recorder:       recorder,
 	}
 
 	s.validateProjectExistsFn = s.validateProjectExists
diff --git a/internal/cli/cmd/server/server.go b/internal/cli/cmd/server/server.go
index 3c8c9259..8f90884e 100644
--- a/internal/cli/cmd/server/server.go
+++ b/internal/cli/cmd/server/server.go
@@ -14,6 +14,7 @@ import (
 	"github.com/akuity/kargo/internal/api/kubernetes"
 	"github.com/akuity/kargo/internal/cli/option"
 	"github.com/akuity/kargo/internal/cli/templates"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 )
 
 type serverOptions struct {
@@ -99,6 +100,7 @@ func (o *serverOptions) run(ctx context.Context) error {
 		},
 		client,
 		client,
+		&fakekubeclient.EventRecorder{},
 	)
 	if err := srv.Serve(ctx, l); err != nil {
 		return fmt.Errorf("serve error: %w", err)
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 3c7068a8..04ab9668 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -6,8 +6,11 @@ import (
 	"sync"
 	"time"
 
+	"github.com/kelseyhightower/envconfig"
 	log "github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/manager"
@@ -24,11 +27,34 @@ import (
 	"github.com/akuity/kargo/internal/logging"
 )
 
+// ReconcilerConfig represents configuration for the promotion reconciler.
+type ReconcilerConfig struct {
+	ShardName string `envconfig:"SHARD_NAME"`
+}
+
+func (c ReconcilerConfig) Name() string {
+	name := "promotion-controller"
+	if c.ShardName != "" {
+		return name + "-" + c.ShardName
+	}
+	return name
+}
+
+func ReconcilerConfigFromEnv() ReconcilerConfig {
+	var cfg ReconcilerConfig
+	envconfig.MustProcess("", &cfg)
+	return cfg
+}
+
 // reconciler reconciles Promotion resources.
 type reconciler struct {
 	kargoClient     client.Client
 	promoMechanisms promotion.Mechanism
 
+	cfg ReconcilerConfig
+
+	recorder record.EventRecorder
+
 	pqs            *promoQueues
 	initializeOnce sync.Once
 
@@ -44,10 +70,10 @@ func SetupReconcilerWithManager(
 	kargoMgr manager.Manager,
 	argocdMgr manager.Manager,
 	credentialsDB credentials.Database,
-	shardName string,
+	cfg ReconcilerConfig,
 ) error {
 
-	shardPredicate, err := controller.GetShardPredicate(shardName)
+	shardPredicate, err := controller.GetShardPredicate(cfg.ShardName)
 	if err != nil {
 		return fmt.Errorf("error creating shard selector predicate: %w", err)
 	}
@@ -60,7 +86,9 @@ func SetupReconcilerWithManager(
 	reconciler := newReconciler(
 		kargoMgr.GetClient(),
 		argocdClient,
+		kargoMgr.GetEventRecorderFor(cfg.Name()),
 		credentialsDB,
+		cfg,
 	)
 
 	changePredicate := predicate.Or(
@@ -108,7 +136,9 @@ func SetupReconcilerWithManager(
 func newReconciler(
 	kargoClient client.Client,
 	argocdClient client.Client,
+	recorder record.EventRecorder,
 	credentialsDB credentials.Database,
+	cfg ReconcilerConfig,
 ) *reconciler {
 	pqs := promoQueues{
 		activePromoByStage:        map[types.NamespacedName]string{},
@@ -116,6 +146,8 @@ func newReconciler(
 	}
 	r := &reconciler{
 		kargoClient: kargoClient,
+		recorder:    recorder,
+		cfg:         cfg,
 		pqs:         &pqs,
 		promoMechanisms: promotion.NewMechanisms(
 			argocdClient,
@@ -165,6 +197,18 @@ func (r *reconciler) Reconcile(
 		// Promotion was deleted after the current reconciliation request was issued.
 		return ctrl.Result{}, nil
 	}
+	// Find the Freight
+	freight, err := kargoapi.GetFreight(ctx, r.kargoClient, types.NamespacedName{
+		Namespace: promo.Namespace,
+		Name:      promo.Spec.Freight,
+	})
+	if err != nil {
+		return ctrl.Result{}, fmt.Errorf("get freight: %w", err)
+	}
+	var freightAlias string
+	if freight != nil {
+		freightAlias = freight.Alias
+	}
 
 	logger = logger.WithFields(log.Fields{
 		"namespace": req.NamespacedName.Namespace,
@@ -239,6 +283,38 @@ func (r *reconciler) Reconcile(
 	if err != nil {
 		logger.Errorf("error updating Promotion status: %s", err)
 	}
+
+	// Record event after patching status if new phase is terminal
+	if newStatus.Phase.IsTerminal() {
+		var reason string
+		switch newStatus.Phase {
+		case kargoapi.PromotionPhaseSucceeded:
+			reason = kargoapi.EventReasonPromotionSucceeded
+		case kargoapi.PromotionPhaseFailed:
+			reason = kargoapi.EventReasonPromotionFailed
+		case kargoapi.PromotionPhaseErrored:
+			reason = kargoapi.EventReasonPromotionErrored
+		}
+
+		msg := fmt.Sprintf("Promotion %s", newStatus.Phase)
+		if newStatus.Message != "" {
+			msg += fmt.Sprintf(": %s", newStatus.Message)
+		}
+
+		eventAnnotations := map[string]string{
+			kargoapi.AnnotationKeyEventActor:               kargoapi.FormatEventControllerActor(r.cfg.Name()),
+			kargoapi.AnnotationKeyEventProject:             promo.GetNamespace(),
+			kargoapi.AnnotationKeyEventPromotionName:       promo.GetName(),
+			kargoapi.AnnotationKeyEventPromotionCreateTime: promo.GetCreationTimestamp().Format(time.RFC3339),
+			kargoapi.AnnotationKeyEventFreightName:         promo.Spec.Freight,
+			kargoapi.AnnotationKeyEventStageName:           promo.Spec.Stage,
+		}
+		if freightAlias != "" {
+			eventAnnotations[kargoapi.AnnotationKeyEventFreightAlias] = freightAlias
+		}
+		r.recorder.AnnotatedEventf(promo, eventAnnotations, corev1.EventTypeNormal, reason, msg)
+	}
+
 	if clearRefreshErr := kargoapi.ClearAnnotations(
 		ctx,
 		r.kargoClient,
diff --git a/internal/controller/stages/stages.go b/internal/controller/stages/stages.go
index ab3e0387..04aa3902 100644
--- a/internal/controller/stages/stages.go
+++ b/internal/controller/stages/stages.go
@@ -9,9 +9,11 @@ import (
 
 	"github.com/kelseyhightower/envconfig"
 	log "github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -37,6 +39,14 @@ type ReconcilerConfig struct {
 	RolloutsControllerInstanceID string `envconfig:"ROLLOUTS_CONTROLLER_INSTANCE_ID"`
 }
 
+func (c ReconcilerConfig) Name() string {
+	name := "stage-controller"
+	if c.ShardName != "" {
+		return name + "-" + c.ShardName
+	}
+	return name
+}
+
 func ReconcilerConfigFromEnv() ReconcilerConfig {
 	cfg := ReconcilerConfig{}
 	envconfig.MustProcess("", &cfg)
@@ -48,6 +58,8 @@ type reconciler struct {
 	kargoClient  client.Client
 	argocdClient client.Client
 
+	recorder record.EventRecorder
+
 	cfg ReconcilerConfig
 
 	// The following behaviors are overridable for testing purposes:
@@ -325,6 +337,7 @@ func SetupReconcilerWithManager(
 			newReconciler(
 				kargoMgr.GetClient(),
 				argocdClient,
+				kargoMgr.GetEventRecorderFor(cfg.Name()),
 				cfg,
 				shardRequirement,
 			),
@@ -437,12 +450,14 @@ func SetupReconcilerWithManager(
 func newReconciler(
 	kargoClient client.Client,
 	argocdClient client.Client,
+	recorder record.EventRecorder,
 	cfg ReconcilerConfig,
 	shardRequirement *labels.Requirement,
 ) *reconciler {
 	r := &reconciler{
 		kargoClient:      kargoClient,
 		argocdClient:     argocdClient,
+		recorder:         recorder,
 		cfg:              cfg,
 		shardRequirement: shardRequirement,
 	}
@@ -663,6 +678,14 @@ func (r *reconciler) syncControlFlowStage(
 					err,
 				)
 			}
+
+			r.recordFreightVerificationEvent(
+				stage,
+				&af,
+				&kargoapi.VerificationInfo{
+					Phase: kargoapi.VerificationPhaseSuccessful,
+				},
+			)
 		}
 	}
 	return status, nil
@@ -839,6 +862,33 @@ func (r *reconciler) syncNormalStage(
 				)
 			}
 		}
+
+		// Record an event if the verification process has completed.
+		if stage.Spec.Verification == nil ||
+			(status.CurrentFreight.VerificationInfo != nil &&
+				status.CurrentFreight.VerificationInfo.Phase.IsTerminal()) {
+			vi := status.CurrentFreight.VerificationInfo
+			if stage.Spec.Verification == nil {
+				vi = &kargoapi.VerificationInfo{
+					Phase: kargoapi.VerificationPhaseSuccessful,
+				}
+			}
+
+			fr, err := r.getFreightFn(
+				ctx,
+				r.kargoClient,
+				types.NamespacedName{
+					Namespace: stage.Namespace,
+					Name:      status.CurrentFreight.Name,
+				},
+			)
+			if err != nil {
+				return status, fmt.Errorf("get freight: %w", err)
+			}
+			if fr != nil {
+				r.recordFreightVerificationEvent(stage, fr, vi)
+			}
+		}
 	}
 
 	// Stop here if we have no chance of finding any Freight to promote.
@@ -935,6 +985,20 @@ func (r *reconciler) syncNormalStage(
 			err,
 		)
 	}
+
+	r.recorder.AnnotatedEventf(
+		&promo,
+		kargoapi.NewPromotionCreatedEventAnnotations(
+			kargoapi.FormatEventControllerActor(r.cfg.Name()),
+			&promo,
+			latestFreight,
+		),
+		corev1.EventTypeNormal,
+		kargoapi.EventReasonPromotionCreated,
+		"Automatically promoted Freight for Stage %q",
+		promo.Spec.Stage,
+	)
+
 	logger.WithField("promotion", promo.Name).Debug("created Promotion resource")
 
 	return status, nil
@@ -1427,3 +1491,39 @@ func (r *reconciler) getLatestApprovedFreight(
 	})
 	return &freight.Items[0], nil
 }
+
+func (r *reconciler) recordFreightVerificationEvent(
+	s *kargoapi.Stage,
+	fr *kargoapi.Freight,
+	vi *kargoapi.VerificationInfo,
+) {
+	annotations := map[string]string{
+		kargoapi.AnnotationKeyEventActor:        kargoapi.FormatEventControllerActor(r.cfg.Name()),
+		kargoapi.AnnotationKeyEventProject:      s.Namespace,
+		kargoapi.AnnotationKeyEventStageName:    s.Namespace,
+		kargoapi.AnnotationKeyEventFreightAlias: fr.Alias,
+		kargoapi.AnnotationKeyEventFreightName:  fr.Name,
+	}
+	if vi.AnalysisRun != nil {
+		annotations[kargoapi.AnnotationKeyEventAnalysisRunName] = vi.AnalysisRun.Name
+	}
+
+	reason := kargoapi.EventReasonFreightVerificationUnknown
+	message := vi.Message
+
+	switch vi.Phase {
+	case kargoapi.VerificationPhaseSuccessful:
+		reason = kargoapi.EventReasonFreightVerificationSucceeded
+		message = "Freight verification succeeded"
+	case kargoapi.VerificationPhaseFailed:
+		reason = kargoapi.EventReasonFreightVerificationFailed
+	case kargoapi.VerificationPhaseError:
+		reason = kargoapi.EventReasonFreightVerificationErrored
+	case kargoapi.VerificationPhaseAborted:
+		reason = kargoapi.EventReasonFreightVerificationAborted
+	case kargoapi.VerificationPhaseInconclusive:
+		reason = kargoapi.EventReasonFreightVerificationInconclusive
+	}
+
+	r.recorder.AnnotatedEventf(fr, annotations, corev1.EventTypeNormal, reason, message)
+}
diff --git a/internal/kubeclient/fake/event.go b/internal/kubeclient/fake/event.go
new file mode 100644
index 00000000..f25e2430
--- /dev/null
+++ b/internal/kubeclient/fake/event.go
@@ -0,0 +1,71 @@
+package fake
+
+import (
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/tools/record"
+)
+
+type Event struct {
+	Annotations map[string]string
+	EventType   string
+	Reason      string
+	Message     string
+	Object      runtime.Object
+}
+
+func NewEvent(
+	annotations map[string]string,
+	eventType,
+	reason,
+	message string,
+	object runtime.Object,
+) Event {
+	return Event{
+		Annotations: annotations,
+		EventType:   eventType,
+		Reason:      reason,
+		Message:     message,
+		Object:      object,
+	}
+}
+
+var (
+	_ record.EventRecorder = &EventRecorder{}
+)
+
+type EventRecorder struct {
+	Events chan Event
+}
+
+func NewEventRecorder(bufferSize int) *EventRecorder {
+	return &EventRecorder{
+		Events: make(chan Event, bufferSize),
+	}
+}
+
+func (r *EventRecorder) Event(object runtime.Object, eventtype, reason, message string) {
+	if r.Events != nil {
+		r.Events <- NewEvent(nil, eventtype, reason, message, object)
+	}
+}
+
+func (r *EventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...any) {
+	if r.Events != nil {
+		r.Events <- NewEvent(nil, eventtype, reason, fmt.Sprintf(messageFmt, args...), object)
+	}
+}
+
+func (r *EventRecorder) AnnotatedEventf(
+	object runtime.Object,
+	annotations map[string]string,
+	eventtype,
+	reason,
+	messageFmt string,
+	args ...any,
+) {
+	if r.Events != nil {
+		r.Events <- NewEvent(annotations, eventtype, reason, fmt.Sprintf(messageFmt, args...), object)
+	}
+}
diff --git a/internal/webhook/admission.go b/internal/webhook/admission.go
new file mode 100644
index 00000000..5cdb009a
--- /dev/null
+++ b/internal/webhook/admission.go
@@ -0,0 +1,19 @@
+package webhook
+
+import (
+	"regexp"
+
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+type IsRequestFromKargoControlplaneFn func(admission.Request) bool
+
+func IsRequestFromKargoControlplane(regex *regexp.Regexp) IsRequestFromKargoControlplaneFn {
+	return func(req admission.Request) bool {
+		// Always return false if regex is not provided
+		if regex == nil {
+			return false
+		}
+		return regex.Match([]byte(req.UserInfo.Username))
+	}
+}
diff --git a/internal/webhook/config.go b/internal/webhook/config.go
new file mode 100644
index 00000000..c424a88e
--- /dev/null
+++ b/internal/webhook/config.go
@@ -0,0 +1,24 @@
+package webhook
+
+import (
+	"regexp"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+type Config struct {
+	// RawControlplaneUserRegex is a regular expression to match the username in
+	// admission request to distinguish if the request is coming from controlplane.
+	RawControlplaneUserRegex string         `envconfig:"CONTROLPLANE_USER_REGEX"`
+	ControlplaneUserRegex    *regexp.Regexp `ignored:"true"`
+}
+
+func ConfigFromEnv() Config {
+	var cfg Config
+	envconfig.MustProcess("", &cfg)
+
+	if cfg.RawControlplaneUserRegex != "" {
+		cfg.ControlplaneUserRegex = regexp.MustCompile(cfg.RawControlplaneUserRegex)
+	}
+	return cfg
+}
diff --git a/internal/webhook/freight/webhook.go b/internal/webhook/freight/webhook.go
index 456ffdf1..e4409026 100644
--- a/internal/webhook/freight/webhook.go
+++ b/internal/webhook/freight/webhook.go
@@ -7,10 +7,12 @@ import (
 
 	"github.com/technosophos/moniker"
 	admissionv1 "k8s.io/api/admission/v1"
+	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/validation/field"
+	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -35,8 +37,12 @@ type webhook struct {
 	client                client.Client
 	freightAliasGenerator moniker.Namer
 
+	recorder record.EventRecorder
+
 	// The following behaviors are overridable for testing purposes:
 
+	admissionRequestFromContextFn func(context.Context) (admission.Request, error)
+
 	getAvailableFreightAliasFn func(context.Context) (string, error)
 
 	validateProjectFn func(
@@ -57,10 +63,19 @@ type webhook struct {
 		client.ObjectList,
 		...client.ListOption,
 	) error
+
+	isRequestFromKargoControlplaneFn libWebhook.IsRequestFromKargoControlplaneFn
 }
 
-func SetupWebhookWithManager(mgr ctrl.Manager) error {
-	w := newWebhook(mgr.GetClient())
+func SetupWebhookWithManager(
+	cfg libWebhook.Config,
+	mgr ctrl.Manager,
+) error {
+	w := newWebhook(
+		cfg,
+		mgr.GetClient(),
+		mgr.GetEventRecorderFor("freight-webhook"),
+	)
 	return ctrl.NewWebhookManagedBy(mgr).
 		For(&kargoapi.Freight{}).
 		WithValidator(w).
@@ -68,15 +83,23 @@ func SetupWebhookWithManager(mgr ctrl.Manager) error {
 		Complete()
 }
 
-func newWebhook(kubeClient client.Client) *webhook {
+func newWebhook(
+	cfg libWebhook.Config,
+	kubeClient client.Client,
+	recorder record.EventRecorder,
+) *webhook {
 	w := &webhook{
 		client:                kubeClient,
 		freightAliasGenerator: moniker.New(),
+		recorder:              recorder,
 	}
+	w.admissionRequestFromContextFn = admission.RequestFromContext
 	w.getAvailableFreightAliasFn = w.getAvailableFreightAlias
 	w.validateProjectFn = libWebhook.ValidateProject
 	w.listFreightFn = kubeClient.List
 	w.listStagesFn = kubeClient.List
+	w.isRequestFromKargoControlplaneFn =
+		libWebhook.IsRequestFromKargoControlplane(cfg.ControlplaneUserRegex)
 	return w
 }
 
@@ -211,6 +234,19 @@ func (w *webhook) ValidateUpdate(
 			},
 		)
 	}
+
+	req, err := w.admissionRequestFromContextFn(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get admission request from context: %w", err)
+	}
+	// Record Freight approved events if the request doesn't come from Kargo controlplane.
+	if !w.isRequestFromKargoControlplaneFn(req) {
+		for approvedStage := range newFreight.Status.ApprovedFor {
+			if _, ok := oldFreight.Status.ApprovedFor[approvedStage]; !ok {
+				w.recordFreightApprovedEvent(req, newFreight, approvedStage)
+			}
+		}
+	}
 	return nil, nil
 }
 
@@ -245,3 +281,20 @@ func (w *webhook) ValidateDelete(
 	}
 	return nil, nil
 }
+
+func (w *webhook) recordFreightApprovedEvent(
+	req admission.Request,
+	f *kargoapi.Freight,
+	stageName string,
+) {
+	actor := kargoapi.FormatEventKubernetesUserActor(req.UserInfo)
+	w.recorder.AnnotatedEventf(
+		f,
+		kargoapi.NewFreightApprovedEventAnnotations(actor, f, stageName),
+		corev1.EventTypeNormal,
+		kargoapi.EventReasonFreightApproved,
+		"Freight approved for Stage %q by %q",
+		stageName,
+		actor,
+	)
+}
diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/promotion/webhook.go
index 2f640440..ea8ddcc8 100644
--- a/internal/webhook/promotion/webhook.go
+++ b/internal/webhook/promotion/webhook.go
@@ -5,12 +5,14 @@ import (
 	"fmt"
 
 	authzv1 "k8s.io/api/authorization/v1"
+	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/validation/field"
+	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -34,8 +36,16 @@ var (
 type webhook struct {
 	client client.Client
 
+	recorder record.EventRecorder
+
 	// The following behaviors are overridable for testing purposes:
 
+	getFreightFn func(
+		context.Context,
+		client.Client,
+		types.NamespacedName,
+	) (*kargoapi.Freight, error)
+
 	getStageFn func(
 		context.Context,
 		client.Client,
@@ -62,10 +72,19 @@ type webhook struct {
 		client.Object,
 		...client.CreateOption,
 	) error
+
+	isRequestFromKargoControlplaneFn libWebhook.IsRequestFromKargoControlplaneFn
 }
 
-func SetupWebhookWithManager(mgr ctrl.Manager) error {
-	w := newWebhook(mgr.GetClient())
+func SetupWebhookWithManager(
+	cfg libWebhook.Config,
+	mgr ctrl.Manager,
+) error {
+	w := newWebhook(
+		cfg,
+		mgr.GetClient(),
+		mgr.GetEventRecorderFor("promotion-webhook"),
+	)
 	return ctrl.NewWebhookManagedBy(mgr).
 		For(&kargoapi.Promotion{}).
 		WithDefaulter(w).
@@ -73,15 +92,23 @@ func SetupWebhookWithManager(mgr ctrl.Manager) error {
 		Complete()
 }
 
-func newWebhook(kubeClient client.Client) *webhook {
+func newWebhook(
+	cfg libWebhook.Config,
+	kubeClient client.Client,
+	recorder record.EventRecorder,
+) *webhook {
 	w := &webhook{
-		client: kubeClient,
+		client:   kubeClient,
+		recorder: recorder,
 	}
+	w.getFreightFn = kargoapi.GetFreight
 	w.getStageFn = kargoapi.GetStage
 	w.validateProjectFn = libWebhook.ValidateProject
 	w.authorizeFn = w.authorize
 	w.admissionRequestFromContextFn = admission.RequestFromContext
 	w.createSubjectAccessReviewFn = w.client.Create
+	w.isRequestFromKargoControlplaneFn =
+		libWebhook.IsRequestFromKargoControlplane(cfg.ControlplaneUserRegex)
 	return w
 }
 
@@ -136,7 +163,28 @@ func (w *webhook) ValidateCreate(
 		w.validateProjectFn(ctx, w.client, promotionGroupKind, promo); err != nil {
 		return nil, err
 	}
-	return nil, w.authorizeFn(ctx, promo, "create")
+
+	if err := w.authorizeFn(ctx, promo, "create"); err != nil {
+		return nil, err
+	}
+
+	req, err := w.admissionRequestFromContextFn(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("get admission request from context: %w", err)
+	}
+
+	// Record Promotion created event if the request doesn't come from Kargo controlplane
+	if !w.isRequestFromKargoControlplaneFn(req) {
+		freight, err := w.getFreightFn(ctx, w.client, types.NamespacedName{
+			Namespace: promo.Namespace,
+			Name:      promo.Spec.Freight,
+		})
+		if err != nil {
+			return nil, fmt.Errorf("get freight: %w", err)
+		}
+		w.recordPromotionCreatedEvent(req, promo, freight)
+	}
+	return nil, nil
 }
 
 func (w *webhook) ValidateUpdate(
@@ -235,3 +283,20 @@ func (w *webhook) authorize(
 
 	return nil
 }
+
+func (w *webhook) recordPromotionCreatedEvent(
+	req admission.Request,
+	p *kargoapi.Promotion,
+	f *kargoapi.Freight,
+) {
+	actor := kargoapi.FormatEventKubernetesUserActor(req.UserInfo)
+	w.recorder.AnnotatedEventf(
+		p,
+		kargoapi.NewPromotionCreatedEventAnnotations(actor, p, f),
+		corev1.EventTypeNormal,
+		kargoapi.EventReasonPromotionCreated,
+		"Promotion created for Stage %q by %q",
+		p.Spec.Stage,
+		actor,
+	)
+}


Test patch:
diff --git a/internal/api/approve_freight_v1alpha1_test.go b/internal/api/approve_freight_v1alpha1_test.go
index 551d5514..45477e8f 100644
--- a/internal/api/approve_freight_v1alpha1_test.go
+++ b/internal/api/approve_freight_v1alpha1_test.go
@@ -7,11 +7,13 @@ import (
 
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -20,7 +22,12 @@ func TestApproveFreight(t *testing.T) {
 		name       string
 		req        *svcv1alpha1.ApproveFreightRequest
 		server     *server
-		assertions func(*testing.T, *connect.Response[svcv1alpha1.ApproveFreightResponse], error)
+		assertions func(
+			*testing.T,
+			*fakekubeclient.EventRecorder,
+			*connect.Response[svcv1alpha1.ApproveFreightResponse],
+			error,
+		)
 	}{
 		{
 			name:   "input validation error",
@@ -28,6 +35,7 @@ func TestApproveFreight(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -51,6 +59,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -81,6 +90,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -111,6 +121,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -152,6 +163,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -189,6 +201,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -239,6 +252,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -292,6 +306,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -345,20 +360,27 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
 				require.NoError(t, err)
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, corev1.EventTypeNormal, event.EventType)
+				require.Equal(t, kargoapi.EventReasonFreightApproved, event.Reason)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.server.recorder = recorder
 			resp, err := testCase.server.ApproveFreight(
 				context.Background(),
 				connect.NewRequest(testCase.req),
 			)
-			testCase.assertions(t, resp, err)
+			testCase.assertions(t, recorder, resp, err)
 		})
 	}
 }
diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1_test.go b/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
index 18f65d8d..a6c874f5 100644
--- a/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
+++ b/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
@@ -7,11 +7,13 @@ import (
 
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -22,6 +24,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 		server     *server
 		assertions func(
 			*testing.T,
+			*fakekubeclient.EventRecorder,
 			*connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 			error,
 		)
@@ -31,6 +34,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -54,6 +58,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -82,6 +87,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -110,6 +116,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -159,6 +166,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -204,6 +212,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -256,6 +265,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -314,6 +324,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -368,6 +379,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -437,6 +449,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -507,6 +520,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -580,22 +594,29 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				res *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
 				require.NoError(t, err)
 				require.NotNil(t, res)
 				require.NotEmpty(t, res.Msg.GetPromotions())
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, corev1.EventTypeNormal, event.EventType)
+				require.Equal(t, kargoapi.EventReasonPromotionCreated, event.Reason)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.server.recorder = recorder
 			resp, err := testCase.server.PromoteToStageSubscribers(
 				context.Background(),
 				connect.NewRequest(testCase.req),
 			)
-			testCase.assertions(t, resp, err)
+			testCase.assertions(t, recorder, resp, err)
 		})
 	}
 }
diff --git a/internal/api/promote_to_stage_v1alpha1_test.go b/internal/api/promote_to_stage_v1alpha1_test.go
index fe300862..47f9eb2f 100644
--- a/internal/api/promote_to_stage_v1alpha1_test.go
+++ b/internal/api/promote_to_stage_v1alpha1_test.go
@@ -7,11 +7,13 @@ import (
 
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -22,6 +24,7 @@ func TestPromoteToStage(t *testing.T) {
 		server     *server
 		assertions func(
 			*testing.T,
+			*fakekubeclient.EventRecorder,
 			*connect.Response[svcv1alpha1.PromoteToStageResponse],
 			error,
 		)
@@ -32,6 +35,7 @@ func TestPromoteToStage(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -55,6 +59,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -83,6 +88,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -111,6 +117,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -160,6 +167,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -205,6 +213,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -257,6 +266,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -320,6 +330,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -384,6 +395,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -448,22 +460,29 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				res *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
 				require.NoError(t, err)
 				require.NotNil(t, res)
 				require.NotNil(t, res.Msg.GetPromotion())
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, corev1.EventTypeNormal, event.EventType)
+				require.Equal(t, kargoapi.EventReasonPromotionCreated, event.Reason)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.server.recorder = recorder
 			res, err := testCase.server.PromoteToStage(
 				context.Background(),
 				connect.NewRequest(testCase.req),
 			)
-			testCase.assertions(t, res, err)
+			testCase.assertions(t, recorder, res, err)
 		})
 	}
 }
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index e7fad298..7953ed8b 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/akuity/kargo/internal/api/config"
 	"github.com/akuity/kargo/internal/api/kubernetes"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 )
 
 func TestNewServer(t *testing.T) {
@@ -30,16 +31,19 @@ func TestNewServer(t *testing.T) {
 		},
 	)
 	require.NoError(t, err)
+	testRecorder := fakekubeclient.NewEventRecorder(0)
 
 	s, ok := NewServer(
 		testServerConfig,
 		testClient,
 		fake.NewClientBuilder().Build(),
+		testRecorder,
 	).(*server)
 
 	require.True(t, ok)
 	require.NotNil(t, s)
 	require.Same(t, testClient, s.client)
+	require.Same(t, testRecorder, s.recorder)
 	require.Equal(t, testServerConfig, s.cfg)
 	require.NotNil(t, s.validateProjectExistsFn)
 	require.NotNil(t, s.externalValidateProjectFn)
diff --git a/internal/controller/promotions/promotions_test.go b/internal/controller/promotions/promotions_test.go
index 6ccbdd65..c56c0b01 100644
--- a/internal/controller/promotions/promotions_test.go
+++ b/internal/controller/promotions/promotions_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/akuity/kargo/api/v1alpha1"
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/credentials"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 )
 
 func TestNewPromotionReconciler(t *testing.T) {
@@ -22,14 +23,20 @@ func TestNewPromotionReconciler(t *testing.T) {
 	r := newReconciler(
 		kubeClient,
 		kubeClient,
+		&fakekubeclient.EventRecorder{},
 		&credentials.FakeDB{},
+		ReconcilerConfig{},
 	)
 	require.NotNil(t, r.kargoClient)
 	require.NotNil(t, r.pqs.pendingPromoQueuesByStage)
 	require.NotNil(t, r.promoteFn)
 }
 
-func newFakeReconciler(t *testing.T, objects ...client.Object) *reconciler {
+func newFakeReconciler(
+	t *testing.T,
+	recorder *fakekubeclient.EventRecorder,
+	objects ...client.Object,
+) *reconciler {
 	scheme := k8sruntime.NewScheme()
 	require.NoError(t, kargoapi.SchemeBuilder.AddToScheme(scheme))
 	kargoClient := fake.NewClientBuilder().WithScheme(scheme).
@@ -38,7 +45,9 @@ func newFakeReconciler(t *testing.T, objects ...client.Object) *reconciler {
 	return newReconciler(
 		kargoClient,
 		kubeClient,
+		recorder,
 		&credentials.FakeDB{},
+		ReconcilerConfig{},
 	)
 }
 
@@ -50,11 +59,15 @@ func TestReconcile(t *testing.T) {
 		promoToReconcile      *types.NamespacedName // if nil, uses the first of the promos
 		expectPromoteFnCalled bool
 		expectedPhase         kargoapi.PromotionPhase
+		expectedEventRecorded bool
+		expectedEventReason   string
 	}{
 		{
 			name:                  "normal reconcile",
 			expectPromoteFnCalled: true,
 			expectedPhase:         kargoapi.PromotionPhaseSucceeded,
+			expectedEventRecorded: true,
+			expectedEventReason:   kargoapi.EventReasonPromotionSucceeded,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo", "fake-stage", kargoapi.PromotionPhasePending, now),
 			},
@@ -68,6 +81,8 @@ func TestReconcile(t *testing.T) {
 			name:                  "promo already completed",
 			expectPromoteFnCalled: false,
 			expectedPhase:         kargoapi.PromotionPhaseErrored,
+			expectedEventRecorded: false,
+			expectedEventReason:   kargoapi.EventReasonPromotionErrored,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo", "fake-stage", kargoapi.PromotionPhaseErrored, now),
 			},
@@ -76,6 +91,8 @@ func TestReconcile(t *testing.T) {
 			name:                  "promo already running",
 			expectPromoteFnCalled: true,
 			expectedPhase:         kargoapi.PromotionPhaseSucceeded,
+			expectedEventRecorded: true,
+			expectedEventReason:   kargoapi.EventReasonPromotionSucceeded,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo", "fake-stage", kargoapi.PromotionPhaseRunning, now),
 			},
@@ -95,6 +112,8 @@ func TestReconcile(t *testing.T) {
 			expectPromoteFnCalled: true,
 			promoToReconcile:      &types.NamespacedName{Namespace: "fake-namespace", Name: "fake-promo1"},
 			expectedPhase:         kargoapi.PromotionPhaseSucceeded,
+			expectedEventRecorded: true,
+			expectedEventReason:   kargoapi.EventReasonPromotionSucceeded,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo1", "fake-stage", kargoapi.PromotionPhasePending, before),
 				newPromo("fake-namespace", "fake-promo2", "fake-stage", kargoapi.PromotionPhasePending, now),
@@ -104,6 +123,8 @@ func TestReconcile(t *testing.T) {
 			name:                  "promoteFn panics",
 			expectPromoteFnCalled: true,
 			expectedPhase:         kargoapi.PromotionPhaseErrored,
+			expectedEventRecorded: true,
+			expectedEventReason:   kargoapi.EventReasonPromotionErrored,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo", "fake-stage", kargoapi.PromotionPhasePending, before),
 			},
@@ -115,6 +136,8 @@ func TestReconcile(t *testing.T) {
 			name:                  "promoteFn errors",
 			expectPromoteFnCalled: true,
 			expectedPhase:         kargoapi.PromotionPhaseErrored,
+			expectedEventRecorded: true,
+			expectedEventReason:   kargoapi.EventReasonPromotionErrored,
 			promos: []client.Object{
 				newPromo("fake-namespace", "fake-promo", "fake-stage", kargoapi.PromotionPhasePending, before),
 			},
@@ -126,7 +149,8 @@ func TestReconcile(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx := context.TODO()
-			r := newFakeReconciler(t, tc.promos...)
+			recorder := fakekubeclient.NewEventRecorder(1)
+			r := newFakeReconciler(t, recorder, tc.promos...)
 			promoteWasCalled := false
 			r.promoteFn = func(ctx context.Context, p v1alpha1.Promotion) (*kargoapi.PromotionStatus, error) {
 				promoteWasCalled = true
@@ -155,6 +179,11 @@ func TestReconcile(t *testing.T) {
 				err = r.kargoClient.Get(ctx, req.NamespacedName, &updatedPromo)
 				require.NoError(t, err)
 				require.Equal(t, tc.expectedPhase, updatedPromo.Status.Phase)
+				if tc.expectedEventRecorded {
+					require.Len(t, recorder.Events, 1)
+					event := <-recorder.Events
+					require.Equal(t, tc.expectedEventReason, event.Reason)
+				}
 			}
 		})
 	}
@@ -167,7 +196,8 @@ func TestReconcileInitializeQueues(t *testing.T) {
 		newPromo("fake-namespace", "fake-promo1", "fake-stage", kargoapi.PromotionPhasePending, before),
 		newPromo("fake-namespace", "fake-promo2", "fake-stage", kargoapi.PromotionPhasePending, now),
 	}
-	r := newFakeReconciler(t, promos...)
+	recorder := &fakekubeclient.EventRecorder{}
+	r := newFakeReconciler(t, recorder, promos...)
 
 	// reconcile a non-existent promo to trigger initializeQueues
 	req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "does-not-exist", Name: "does-not-exist"}}
diff --git a/internal/controller/stages/stages_test.go b/internal/controller/stages/stages_test.go
index d70c46a4..1cae2b30 100644
--- a/internal/controller/stages/stages_test.go
+++ b/internal/controller/stages/stages_test.go
@@ -15,6 +15,7 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/controller"
 	rollouts "github.com/akuity/kargo/internal/controller/rollouts/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
 )
 
 func TestNewReconciler(t *testing.T) {
@@ -24,15 +25,18 @@ func TestNewReconciler(t *testing.T) {
 	kubeClient := fake.NewClientBuilder().Build()
 	requirement, err := controller.GetShardRequirement(testCfg.ShardName)
 	require.NoError(t, err)
+	recorder := &fakekubeclient.EventRecorder{Events: nil}
 	r := newReconciler(
 		kubeClient,
 		kubeClient,
+		recorder,
 		testCfg,
 		requirement,
 	)
 	require.Equal(t, testCfg, r.cfg)
 	require.NotNil(t, r.kargoClient)
 	require.NotNil(t, r.argocdClient)
+	require.NotNil(t, r.recorder)
 	// Assert that all overridable behaviors were initialized to a default:
 	// Loop guard:
 	require.NotNil(t, r.hasNonTerminalPromotionsFn)
@@ -75,6 +79,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 		reconciler *reconciler
 		assertions func(
 			t *testing.T,
+			recorder *fakekubeclient.EventRecorder,
 			initialStatus kargoapi.StageStatus,
 			newStatus kargoapi.StageStatus,
 			err error,
@@ -103,6 +108,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -112,6 +118,9 @@ func TestSyncControlFlowStage(t *testing.T) {
 				require.Contains(t, err.Error(), "something went wrong")
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 		{
@@ -139,6 +148,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -152,6 +162,9 @@ func TestSyncControlFlowStage(t *testing.T) {
 				require.Contains(t, err.Error(), "something went wrong")
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 		{
@@ -187,6 +200,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -196,6 +210,9 @@ func TestSyncControlFlowStage(t *testing.T) {
 				require.Contains(t, err.Error(), "something went wrong")
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 		{
@@ -236,6 +253,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -244,16 +262,22 @@ func TestSyncControlFlowStage(t *testing.T) {
 				require.Equal(t, int64(42), newStatus.ObservedGeneration) // Set
 				require.Nil(t, newStatus.CurrentFreight)                  // Cleared
 				require.Nil(t, newStatus.Health)                          // Cleared
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.reconciler.recorder = recorder
 			newStatus, err := testCase.reconciler.syncControlFlowStage(
 				context.Background(),
 				testCase.stage,
 			)
-			testCase.assertions(t, testCase.stage.Status, newStatus, err)
+			testCase.assertions(t, recorder, testCase.stage.Status, newStatus, err)
 		})
 	}
 }
@@ -273,6 +297,7 @@ func TestSyncNormalStage(t *testing.T) {
 		reconciler *reconciler
 		assertions func(
 			t *testing.T,
+			recorder *fakekubeclient.EventRecorder,
 			initialStatus kargoapi.StageStatus,
 			newStatus kargoapi.StageStatus,
 			err error,
@@ -292,6 +317,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -300,6 +326,9 @@ func TestSyncNormalStage(t *testing.T) {
 				require.Equal(t, "something went wrong", err.Error())
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 
@@ -317,6 +346,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -324,6 +354,9 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 
@@ -377,6 +410,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -396,6 +430,9 @@ func TestSyncNormalStage(t *testing.T) {
 					},
 					newStatus.CurrentFreight.VerificationInfo,
 				)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 
@@ -440,9 +477,17 @@ func TestSyncNormalStage(t *testing.T) {
 				) *kargoapi.Health {
 					return nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -450,6 +495,10 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				require.NotNil(t, newStatus.CurrentFreight)
 				require.Equal(t, initialStatus, newStatus)
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationFailed, event.Reason)
 			},
 		},
 
@@ -483,9 +532,17 @@ func TestSyncNormalStage(t *testing.T) {
 						Message: "something went wrong",
 					}, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -514,6 +571,10 @@ func TestSyncNormalStage(t *testing.T) {
 				newStatus.CurrentFreight.VerificationHistory = nil
 				newStatus.Phase = initialStatus.Phase
 				require.Equal(t, initialStatus, newStatus)
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationErrored, event.Reason)
 			},
 		},
 
@@ -550,6 +611,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -611,6 +673,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) *kargoapi.Health {
 					return nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 				getVerificationInfoFn: func(_ context.Context, _ *kargoapi.Stage) (*kargoapi.VerificationInfo, error) {
 					return &kargoapi.VerificationInfo{
 						Phase:   kargoapi.VerificationPhaseError,
@@ -620,6 +689,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -640,6 +710,10 @@ func TestSyncNormalStage(t *testing.T) {
 				newStatus.Phase = initialStatus.Phase
 				newStatus.CurrentFreight = initialStatus.CurrentFreight
 				require.Equal(t, initialStatus, newStatus)
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationErrored, event.Reason)
 			},
 		},
 
@@ -685,6 +759,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -748,6 +823,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) *kargoapi.Health {
 					return nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 				getVerificationInfoFn: func(
 					_ context.Context,
 					s *kargoapi.Stage,
@@ -766,6 +848,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -783,6 +866,10 @@ func TestSyncNormalStage(t *testing.T) {
 
 				// Phase should be changed to Steady
 				require.Equal(t, kargoapi.StagePhaseSteady, newStatus.Phase)
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationAborted, event.Reason)
 			},
 		},
 
@@ -820,6 +907,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) *kargoapi.Health {
 					return nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 				getVerificationInfoFn: func(
 					_ context.Context,
 					s *kargoapi.Stage,
@@ -841,6 +935,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -848,6 +943,10 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				require.NotNil(t, newStatus.CurrentFreight)
 				require.Equal(t, kargoapi.VerificationPhaseError, newStatus.CurrentFreight.VerificationInfo.Phase)
+
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationErrored, event.Reason)
 			},
 		},
 
@@ -877,6 +976,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -890,6 +990,9 @@ func TestSyncNormalStage(t *testing.T) {
 				// Status should be otherwise unchanged
 				newStatus.Phase = initialStatus.Phase
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 
@@ -925,13 +1028,26 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return false, errors.New("something went wrong")
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
 			) {
+				// Verification should be done before auto-promotion
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
+
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "something went wrong")
 				require.Contains(
@@ -976,13 +1092,26 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return false, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
 			) {
+				// Verification should be done before auto-promotion
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
+
 				require.NoError(t, err)
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
@@ -1021,6 +1150,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return true, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 				getLatestAvailableFreightFn: func(
 					context.Context,
 					string,
@@ -1031,10 +1167,16 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
 			) {
+				// Verification should be done before auto-promotion
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
+
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "something went wrong")
 				require.Contains(
@@ -1069,6 +1211,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) *kargoapi.Health {
 					return nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return nil, nil
+				},
 				verifyFreightInStageFn: func(context.Context, string, string, string) error {
 					return nil
 				},
@@ -1089,6 +1238,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1096,6 +1246,9 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				// Status should be returned unchanged
 				require.Equal(t, initialStatus, newStatus)
+
+				// No events should be recorded
+				require.Empty(t, recorder.Events)
 			},
 		},
 
@@ -1133,6 +1286,13 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return true, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
 				getLatestAvailableFreightFn: func(
 					context.Context,
 					string,
@@ -1147,6 +1307,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1189,6 +1350,17 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return true, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "fake-freight-id",
+						},
+					}, nil
+				},
 				getLatestAvailableFreightFn: func(
 					context.Context,
 					string,
@@ -1213,6 +1385,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				_ *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1255,6 +1428,17 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return true, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "fake-freight-id",
+						},
+					}, nil
+				},
 				getLatestAvailableFreightFn: func(
 					context.Context,
 					string,
@@ -1283,10 +1467,16 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
 			) {
+				// Verification should be done before promotion
+				require.Len(t, recorder.Events, 1)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
+
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "something went wrong")
 				require.Contains(t, err.Error(), "error creating Promotion of Stage")
@@ -1358,6 +1548,17 @@ func TestSyncNormalStage(t *testing.T) {
 				) (bool, error) {
 					return true, nil
 				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "fake-freight-id",
+						},
+					}, nil
+				},
 				getLatestAvailableFreightFn: func(
 					context.Context,
 					string,
@@ -1386,6 +1587,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
+				recorder *fakekubeclient.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1408,13 +1610,22 @@ func TestSyncNormalStage(t *testing.T) {
 					},
 					newStatus.CurrentFreight.VerificationInfo,
 				)
+
+				require.Len(t, recorder.Events, 2)
+				event := <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonFreightVerificationSucceeded, event.Reason)
+				// The second event should be the promotion creation event (auto-promotion)
+				event = <-recorder.Events
+				require.Equal(t, kargoapi.EventReasonPromotionCreated, event.Reason)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(2)
+			testCase.reconciler.recorder = recorder
 			newStatus, err := testCase.reconciler.syncNormalStage(context.Background(), testCase.stage)
-			testCase.assertions(t, testCase.stage.Status, newStatus, err)
+			testCase.assertions(t, recorder, testCase.stage.Status, newStatus, err)
 		})
 	}
 }
diff --git a/internal/webhook/admission_test.go b/internal/webhook/admission_test.go
new file mode 100644
index 00000000..911f6ce3
--- /dev/null
+++ b/internal/webhook/admission_test.go
@@ -0,0 +1,69 @@
+package webhook
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	admissionv1 "k8s.io/api/admission/v1"
+	authnv1 "k8s.io/api/authentication/v1"
+	"k8s.io/apiserver/pkg/authentication/serviceaccount"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+func TestIsRequestFromKargoControlplane(t *testing.T) {
+	testCases := map[string]struct {
+		regex    *regexp.Regexp
+		userInfo authnv1.UserInfo
+		expected bool
+	}{
+		"no expression provided": {
+			userInfo: authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			expected: false,
+		},
+		"no match": {
+			regex: regexp.MustCompile("^fake-user$"),
+			userInfo: authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			expected: false,
+		},
+		"unknown service account": {
+			regex: regexp.MustCompile("^system:serviceaccount:kargo:kargo-api$"),
+			userInfo: authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "some-namespace:unknown-service-account",
+			},
+			expected: false,
+		},
+		"known service account": {
+			regex: regexp.MustCompile("^system:serviceaccount:kargo:kargo-api$"),
+			userInfo: authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			expected: true,
+		},
+		"one of known service accounts": {
+			regex: regexp.MustCompile("^system:serviceaccount:kargo:[a-z0-9]([-a-z0-9]*[a-z0-9])?$"),
+			userInfo: authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			expected: true,
+		},
+	}
+	for name, tc := range testCases {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			req := admission.Request{
+				AdmissionRequest: admissionv1.AdmissionRequest{
+					UserInfo: tc.userInfo,
+				},
+			}
+			actual := IsRequestFromKargoControlplane(tc.regex)(req)
+			require.Equal(t, tc.expected, actual)
+		})
+	}
+}
diff --git a/internal/webhook/config_test.go b/internal/webhook/config_test.go
new file mode 100644
index 00000000..2d56e9a7
--- /dev/null
+++ b/internal/webhook/config_test.go
@@ -0,0 +1,71 @@
+package webhook
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestConfigFromEnv(t *testing.T) {
+	testCases := map[string]struct {
+		envs     map[string]string
+		assertFn func(*testing.T, func() Config)
+	}{
+		"empty controlplane user regex should not panic": {
+			envs: map[string]string{
+				"CONTROLPLANE_USER_REGEX": "",
+			},
+			assertFn: func(t *testing.T, f func() Config) {
+				var cfg Config
+				require.NotPanics(t, func() {
+					cfg = f()
+				})
+				require.Nil(t, cfg.ControlplaneUserRegex)
+			},
+		},
+		"invalid controlplane user regex should panic": {
+			envs: map[string]string{
+				"CONTROLPLANE_USER_REGEX": "[",
+			},
+			assertFn: func(t *testing.T, f func() Config) {
+				require.Panics(t, func() { f() })
+			},
+		},
+		"default controlplane user regex in helm chart": {
+			envs: map[string]string{
+				"CONTROLPLANE_USER_REGEX": "^system:serviceaccount:kargo:(kargo-api|kargo-controller)$",
+			},
+			assertFn: func(t *testing.T, f func() Config) {
+				var cfg Config
+				require.NotPanics(t, func() {
+					cfg = f()
+				})
+				require.NotNil(t, cfg.ControlplaneUserRegex)
+				require.True(t, cfg.ControlplaneUserRegex.MatchString("system:serviceaccount:kargo:kargo-api"))
+				require.True(t, cfg.ControlplaneUserRegex.MatchString("system:serviceaccount:kargo:kargo-controller"))
+			},
+		},
+		"sample controlplane user regex in helm chart": {
+			envs: map[string]string{
+				"CONTROLPLANE_USER_REGEX": "^system:serviceaccount:kargo:[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+			},
+			assertFn: func(t *testing.T, f func() Config) {
+				var cfg Config
+				require.NotPanics(t, func() {
+					cfg = f()
+				})
+				require.NotNil(t, cfg.ControlplaneUserRegex)
+				require.True(t, cfg.ControlplaneUserRegex.MatchString("system:serviceaccount:kargo:kargo-api"))
+				require.True(t, cfg.ControlplaneUserRegex.MatchString("system:serviceaccount:kargo:kargo-controller"))
+			},
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			for k, v := range tc.envs {
+				t.Setenv(k, v)
+			}
+			tc.assertFn(t, ConfigFromEnv)
+		})
+	}
+}
diff --git a/internal/webhook/freight/webhook_test.go b/internal/webhook/freight/webhook_test.go
index db106fe5..e652ebfb 100644
--- a/internal/webhook/freight/webhook_test.go
+++ b/internal/webhook/freight/webhook_test.go
@@ -4,30 +4,41 @@ import (
 	"context"
 	"errors"
 	"net/http"
+	"regexp"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 	admissionv1 "k8s.io/api/admission/v1"
+	authnv1 "k8s.io/api/authentication/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apiserver/pkg/authentication/serviceaccount"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
 
 func TestNewWebhook(t *testing.T) {
 	kubeClient := fake.NewClientBuilder().Build()
-	w := newWebhook(kubeClient)
+	w := newWebhook(
+		libWebhook.Config{},
+		kubeClient,
+		&fakekubeclient.EventRecorder{},
+	)
 	require.NotNil(t, w.freightAliasGenerator)
 	// Assert that all overridable behaviors were initialized to a default:
+	require.NotNil(t, w.admissionRequestFromContextFn)
 	require.NotNil(t, w.getAvailableFreightAliasFn)
 	require.NotNil(t, w.validateProjectFn)
 	require.NotNil(t, w.listFreightFn)
 	require.NotNil(t, w.listStagesFn)
+	require.NotNil(t, w.isRequestFromKargoControlplaneFn)
 }
 
 func TestDefault(t *testing.T) {
@@ -298,8 +309,9 @@ func TestValidateUpdate(t *testing.T) {
 	testCases := []struct {
 		name       string
 		webhook    *webhook
+		userInfo   *authnv1.UserInfo
 		setup      func() (*kargoapi.Freight, *kargoapi.Freight)
-		assertions func(*testing.T, error)
+		assertions func(*testing.T, *fakekubeclient.EventRecorder, error)
 	}{
 		{
 			name: "error listing freight",
@@ -329,7 +341,7 @@ func TestValidateUpdate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				statusErr, ok := err.(*apierrors.StatusError)
 				require.True(t, ok)
 				require.Equal(
@@ -370,7 +382,7 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				statusErr, ok := err.(*apierrors.StatusError)
 				require.True(t, ok)
 				require.Equal(t, int32(http.StatusConflict), statusErr.Status().Code)
@@ -405,7 +417,7 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "is invalid")
 				require.Contains(t, err.Error(), "freight is immutable")
@@ -435,13 +447,12 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "is invalid")
 				require.Contains(t, err.Error(), "freight is immutable")
 			},
 		},
-
 		{
 			name: "update without mutation",
 			setup: func() (*kargoapi.Freight, *kargoapi.Freight) {
@@ -469,21 +480,130 @@ func TestValidateUpdate(t *testing.T) {
 				) error {
 					return nil
 				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
+					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
+				),
 			},
-			assertions: func(t *testing.T, err error) {
+			userInfo: &authnv1.UserInfo{
+				Username: "fake-user",
+			},
+			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
 				require.NoError(t, err)
+				// Recorder should not record non-freight approval events
+				require.Empty(t, r.Events)
+			},
+		},
+		{
+			name: "record approval event from non-controlplane",
+			setup: func() (*kargoapi.Freight, *kargoapi.Freight) {
+				oldFreight := &kargoapi.Freight{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "fake-name",
+						Namespace: "fake-namespace",
+					},
+					Commits: []kargoapi.GitCommit{
+						{
+							RepoURL: "fake-repo-url",
+							ID:      "fake-commit-id",
+						},
+					},
+					Status: kargoapi.FreightStatus{},
+				}
+				oldFreight.Name = oldFreight.GenerateID()
+				newFreight := oldFreight.DeepCopy()
+				newFreight.Status.ApprovedFor = map[string]kargoapi.ApprovedStage{
+					"fake-stage": {},
+				}
+				return oldFreight, newFreight
+			},
+			webhook: &webhook{
+				listFreightFn: func(
+					context.Context,
+					client.ObjectList,
+					...client.ListOption,
+				) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
+					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
+				),
+			},
+			userInfo: &authnv1.UserInfo{
+				Username: "fake-user",
+			},
+			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+				require.NoError(t, err)
+				require.Len(t, r.Events, 1)
+				event := <-r.Events
+				require.Equal(t, kargoapi.EventReasonFreightApproved, event.Reason)
+			},
+		},
+		{
+			name: "skip recording approval event from controlplane",
+			setup: func() (*kargoapi.Freight, *kargoapi.Freight) {
+				oldFreight := &kargoapi.Freight{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "fake-name",
+						Namespace: "fake-namespace",
+					},
+					Commits: []kargoapi.GitCommit{
+						{
+							RepoURL: "fake-repo-url",
+							ID:      "fake-commit-id",
+						},
+					},
+					Status: kargoapi.FreightStatus{},
+				}
+				oldFreight.Name = oldFreight.GenerateID()
+				newFreight := oldFreight.DeepCopy()
+				newFreight.Status.ApprovedFor = map[string]kargoapi.ApprovedStage{
+					"fake-stage": {},
+				}
+				return oldFreight, newFreight
+			},
+			webhook: &webhook{
+				listFreightFn: func(
+					context.Context,
+					client.ObjectList,
+					...client.ListOption,
+				) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
+					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
+				),
+			},
+			userInfo: &authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+				require.NoError(t, err)
+				require.Empty(t, r.Events)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
 			oldFreight, newFreight := testCase.setup()
+
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.webhook.recorder = recorder
+
+			var req admission.Request
+			if testCase.userInfo != nil {
+				req.UserInfo = *testCase.userInfo
+			}
+			ctx := admission.NewContextWithRequest(context.Background(), req)
+
 			_, err := testCase.webhook.ValidateUpdate(
-				context.Background(),
+				ctx,
 				oldFreight,
 				newFreight,
 			)
-			testCase.assertions(t, err)
+			testCase.assertions(t, recorder, err)
 		})
 	}
 }
diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/promotion/webhook_test.go
index e5118567..8b02b316 100644
--- a/internal/webhook/promotion/webhook_test.go
+++ b/internal/webhook/promotion/webhook_test.go
@@ -3,29 +3,40 @@ package promotion
 import (
 	"context"
 	"errors"
+	"regexp"
 	"testing"
 
 	"github.com/stretchr/testify/require"
+	authnv1 "k8s.io/api/authentication/v1"
 	authzv1 "k8s.io/api/authorization/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apiserver/pkg/authentication/serviceaccount"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
 
 func TestNewWebhook(t *testing.T) {
 	kubeClient := fake.NewClientBuilder().Build()
-	w := newWebhook(kubeClient)
+	w := newWebhook(
+		libWebhook.Config{},
+		kubeClient,
+		&fakekubeclient.EventRecorder{},
+	)
 	// Assert that all overridable behaviors were initialized to a default:
+	require.NotNil(t, w.getFreightFn)
 	require.NotNil(t, w.getStageFn)
 	require.NotNil(t, w.validateProjectFn)
 	require.NotNil(t, w.authorizeFn)
 	require.NotNil(t, w.admissionRequestFromContextFn)
 	require.NotNil(t, w.createSubjectAccessReviewFn)
+	require.NotNil(t, w.isRequestFromKargoControlplaneFn)
 }
 
 func TestDefault(t *testing.T) {
@@ -105,7 +116,8 @@ func TestValidateCreate(t *testing.T) {
 	testCases := []struct {
 		name       string
 		webhook    *webhook
-		assertions func(*testing.T, error)
+		userInfo   *authnv1.UserInfo
+		assertions func(*testing.T, *fakekubeclient.EventRecorder, error)
 	}{
 		{
 			name: "error validating project",
@@ -119,7 +131,7 @@ func TestValidateCreate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Equal(t, "something went wrong", err.Error())
 			},
@@ -139,13 +151,13 @@ func TestValidateCreate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, err error) {
+			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Equal(t, "something went wrong", err.Error())
 			},
 		},
 		{
-			name: "success",
+			name: "record promotion created event on non-controlplane request",
 			webhook: &webhook{
 				validateProjectFn: func(
 					context.Context,
@@ -158,19 +170,76 @@ func TestValidateCreate(t *testing.T) {
 				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
 					return nil
 				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return nil, nil
+				},
+				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
+					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
+				),
 			},
-			assertions: func(t *testing.T, err error) {
+			userInfo: &authnv1.UserInfo{
+				Username: "fake-user",
+			},
+			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
 				require.NoError(t, err)
+				require.Len(t, r.Events, 1)
+				event := <-r.Events
+				require.Equal(t, kargoapi.EventReasonPromotionCreated, event.Reason)
+			},
+		},
+		{
+			name: "skip recording promotion created event on controlplane request",
+			webhook: &webhook{
+				validateProjectFn: func(
+					context.Context,
+					client.Client,
+					schema.GroupKind,
+					client.Object,
+				) error {
+					return nil
+				},
+				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
+					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
+				),
+			},
+			userInfo: &authnv1.UserInfo{
+				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
+			},
+			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+				require.NoError(t, err)
+				require.Empty(t, r.Events)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
+			recorder := fakekubeclient.NewEventRecorder(1)
+			testCase.webhook.recorder = recorder
+
+			var req admission.Request
+			if testCase.userInfo != nil {
+				req.UserInfo = *testCase.userInfo
+			}
+			ctx := admission.NewContextWithRequest(context.Background(), req)
+
 			_, err := testCase.webhook.ValidateCreate(
-				context.Background(),
-				&kargoapi.Promotion{},
+				ctx,
+				&kargoapi.Promotion{
+					Spec: &kargoapi.PromotionSpec{
+						Freight: "fake-freight",
+					},
+				},
 			)
-			testCase.assertions(t, err)
+			testCase.assertions(t, recorder, err)
 		})
 	}
 }