Repository: akuity/kargo
Pull request #1872 (2024-04-19 00:23:34)

feat: set promotion actor to the annotation
This PR allows to controller to track who triggered Promotion by the actor information set by API / Webhook when the Promotion is created.

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


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 30229c943e624da68b90d5b49faeaa549c58519b |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/6621b988fc1b390008d91e6b |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1872?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 `39.39394%` with `20 lines` in your changes are missing coverage. Please review.
> Project coverage is 46.38%. Comparing base [(`7ccc959`)](https://app.codecov.io/gh/akuity/kargo/commit/7ccc9599280ae35bdbe364897dd123e5481ab1fa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`30229c9`)](https://app.codecov.io/gh/akuity/kargo/pull/1872?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/1872?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [internal/webhook/promotion/webhook.go](https://app.codecov.io/gh/akuity/kargo/pull/1872?src=pr&el=tree&filepath=internal%2Fwebhook%2Fpromotion%2Fwebhook.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvd2ViaG9vay9wcm9tb3Rpb24vd2ViaG9vay5nbw==) | 23.80% | [12 Missing and 4 partials :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1872?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [api/v1alpha1/event.go](https://app.codecov.io/gh/akuity/kargo/pull/1872?src=pr&el=tree&filepath=api%2Fv1alpha1%2Fevent.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-YXBpL3YxYWxwaGExL2V2ZW50Lmdv) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1872?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/kargo/kargo.go](https://app.codecov.io/gh/akuity/kargo/pull/1872?src=pr&el=tree&filepath=internal%2Fkargo%2Fkargo.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwva2FyZ28va2FyZ28uZ28=) | 71.42% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1872?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    #1872      +/-   ##
==========================================
- Coverage   46.42%   46.38%   -0.04%     
==========================================
  Files         215      215              
  Lines       14039    14066      +27     
==========================================
+ Hits         6517     6524       +7     
- Misses       7228     7243      +15     
- Partials      294      299       +5     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1872?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).


Patch:
diff --git a/api/v1alpha1/annotations.go b/api/v1alpha1/annotations.go
index 45c17be0..21cc55c5 100644
--- a/api/v1alpha1/annotations.go
+++ b/api/v1alpha1/annotations.go
@@ -3,6 +3,10 @@ package v1alpha1
 import "encoding/json"
 
 const (
+	// AnnotationKeyCreateActor is an annotation key that can be injected to a resource
+	// by the Kargo controlplane to indicate the actor that created the resource.
+	AnnotationKeyCreateActor = "kargo.akuity.io/create-actor"
+
 	// AnnotationKeyRefresh is an annotation key that can be set on a resource
 	// to trigger a refresh of the resource by the controller. The value of the
 	// annotation is interpreted as a token, and any change to the value of the
diff --git a/api/v1alpha1/event.go b/api/v1alpha1/event.go
index 5405bfc2..6b4ac4bd 100644
--- a/api/v1alpha1/event.go
+++ b/api/v1alpha1/event.go
@@ -93,9 +93,16 @@ func NewPromotionEventAnnotations(
 		AnnotationKeyEventStageName:           p.Spec.Stage,
 		AnnotationKeyEventPromotionCreateTime: p.GetCreationTimestamp().Format(time.RFC3339),
 	}
+
 	if actor != "" {
 		annotations[AnnotationKeyEventActor] = actor
 	}
+	// All Promotion-related events are emitted after the promotion was created.
+	// Therefore, if the promotion knows who triggered it, set them as an actor.
+	if promoteActor, ok := p.Annotations[AnnotationKeyCreateActor]; ok {
+		annotations[AnnotationKeyEventActor] = promoteActor
+	}
+
 	if f != nil {
 		annotations[AnnotationKeyEventFreightCreateTime] = f.CreationTimestamp.Format(time.RFC3339)
 		annotations[AnnotationKeyEventFreightAlias] = f.Alias
diff --git a/api/v1alpha1/generated.proto b/api/v1alpha1/generated.proto
index cf8e13a1..f6803b78 100644
--- a/api/v1alpha1/generated.proto
+++ b/api/v1alpha1/generated.proto
@@ -500,27 +500,34 @@ message GitSubscription {
   // only with great caution.
   optional bool insecureSkipTLSVerify = 7;
 
-  // IncludePaths is a list of filter strings that can optionally be used to
-  // limit file paths in repository, changes in which will result in creation of
-  // new freight. When not specified - changes in any path will produce new
-  // freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
-  // When both IncludePaths and ExcludePaths are specified and match same path/paths,
-  // ExcludePaths will prevail over IncludePaths.
-  // Filter strings can be specified as prefix/exact match strings, globs or regular
-  // expressions, default being prefix/exact match, for example "helm/charts/", for
-  // regular expressions strings need to be prepended by either "regex:" or "regexp:"
-  // as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+  // IncludePaths is a list of selectors that designate paths in the repository
+  // that should trigger the production of new Freight when changes are detected
+  // therein. When specified, only changes in the identified paths will trigger
+  // Freight production. When not specified, changes in any path will trigger
+  // Freight production. Selectors may be defined using:
+  //   1. Exact paths to files or directories (ex. "charts/foo")
+  //   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+  //   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+  //      ex. "regexp:^.*\.yaml$")
+  // Paths selected by IncludePaths may be unselected by ExcludePaths. This
+  // is a useful method for including a broad set of paths and then excluding a
+  // subset of them.
   // +kubebuilder:validation:Optional
   repeated string includePaths = 8;
 
-  // ExcludePaths is an optional list of filter strings used to specify paths
-  // in git repository, changes in which should never produce new freight. When used
-  // in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
-  // precedence over IncludePaths.
-  // Filter strings can be specified as prefix/exact match strings, globs or regular
-  // expressions, default being prefix/exact match, for example "helm/charts/", for
-  // regular expressions strings need to be prepended by either "regex:" or "regexp:"
-  // as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+  // ExcludePaths is a list of selectors that designate paths in the repository
+  // that should NOT trigger the production of new Freight when changes are
+  // detected therein. When specified, changes in the identified paths will not
+  // trigger Freight production. When not specified, paths that should trigger
+  // Freight production will be defined solely by IncludePaths. Selectors may be
+  // defined using:
+  //   1. Exact paths to files or directories (ex. "charts/foo")
+  //   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+  //   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+  //      ex. "regexp:^.*\.yaml$")
+  // Paths selected by IncludePaths may be unselected by ExcludePaths. This
+  // is a useful method for including a broad set of paths and then excluding a
+  // subset of them.
   // +kubebuilder:validation:Optional
   repeated string excludePaths = 9;
 }
diff --git a/api/v1alpha1/warehouse_types.go b/api/v1alpha1/warehouse_types.go
index d43dbf67..c9ee70ec 100644
--- a/api/v1alpha1/warehouse_types.go
+++ b/api/v1alpha1/warehouse_types.go
@@ -126,26 +126,33 @@ type GitSubscription struct {
 	// should be ignored when connecting to the repository. This should be enabled
 	// only with great caution.
 	InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty" protobuf:"varint,7,opt,name=insecureSkipTLSVerify"`
-	// IncludePaths is a list of filter strings that can optionally be used to
-	// limit file paths in repository, changes in which will result in creation of
-	// new freight. When not specified - changes in any path will produce new
-	// freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
-	// When both IncludePaths and ExcludePaths are specified and match same path/paths,
-	// ExcludePaths will prevail over IncludePaths.
-	// Filter strings can be specified as prefix/exact match strings, globs or regular
-	// expressions, default being prefix/exact match, for example "helm/charts/", for
-	// regular expressions strings need to be prepended by either "regex:" or "regexp:"
-	// as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+	// IncludePaths is a list of selectors that designate paths in the repository
+	// that should trigger the production of new Freight when changes are detected
+	// therein. When specified, only changes in the identified paths will trigger
+	// Freight production. When not specified, changes in any path will trigger
+	// Freight production. Selectors may be defined using:
+	//   1. Exact paths to files or directories (ex. "charts/foo")
+	//   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+	//   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+	//      ex. "regexp:^.*\.yaml$")
+	// Paths selected by IncludePaths may be unselected by ExcludePaths. This
+	// is a useful method for including a broad set of paths and then excluding a
+	// subset of them.
 	//+kubebuilder:validation:Optional
 	IncludePaths []string `json:"includePaths,omitempty" protobuf:"bytes,8,rep,name=includePaths"`
-	// ExcludePaths is an optional list of filter strings used to specify paths
-	// in git repository, changes in which should never produce new freight. When used
-	// in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
-	// precedence over IncludePaths.
-	// Filter strings can be specified as prefix/exact match strings, globs or regular
-	// expressions, default being prefix/exact match, for example "helm/charts/", for
-	// regular expressions strings need to be prepended by either "regex:" or "regexp:"
-	// as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+	// ExcludePaths is a list of selectors that designate paths in the repository
+	// that should NOT trigger the production of new Freight when changes are
+	// detected therein. When specified, changes in the identified paths will not
+	// trigger Freight production. When not specified, paths that should trigger
+	// Freight production will be defined solely by IncludePaths. Selectors may be
+	// defined using:
+	//   1. Exact paths to files or directories (ex. "charts/foo")
+	//   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+	//   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+	//      ex. "regexp:^.*\.yaml$")
+	// Paths selected by IncludePaths may be unselected by ExcludePaths. This
+	// is a useful method for including a broad set of paths and then excluding a
+	// subset of them.
 	//+kubebuilder:validation:Optional
 	ExcludePaths []string `json:"excludePaths,omitempty" protobuf:"bytes,9,rep,name=excludePaths"`
 }
diff --git a/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml b/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
index 70aff895..1cee9ddb 100644
--- a/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
+++ b/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
@@ -138,14 +138,19 @@ spec:
                           type: string
                         excludePaths:
                           description: |-
-                            ExcludePaths is an optional list of filter strings used to specify paths
-                            in git repository, changes in which should never produce new freight. When used
-                            in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
-                            precedence over IncludePaths.
-                            Filter strings can be specified as prefix/exact match strings, globs or regular
-                            expressions, default being prefix/exact match, for example "helm/charts/", for
-                            regular expressions strings need to be prepended by either "regex:" or "regexp:"
-                            as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+                            ExcludePaths is a list of selectors that designate paths in the repository
+                            that should NOT trigger the production of new Freight when changes are
+                            detected therein. When specified, changes in the identified paths will not
+                            trigger Freight production. When not specified, paths that should trigger
+                            Freight production will be defined solely by IncludePaths. Selectors may be
+                            defined using:
+                              1. Exact paths to files or directories (ex. "charts/foo")
+                              2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+                              3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+                                 ex. "regexp:^.*\.yaml$")
+                            Paths selected by IncludePaths may be unselected by ExcludePaths. This
+                            is a useful method for including a broad set of paths and then excluding a
+                            subset of them.
                           items:
                             type: string
                           type: array
@@ -161,16 +166,18 @@ spec:
                           type: array
                         includePaths:
                           description: |-
-                            IncludePaths is a list of filter strings that can optionally be used to
-                            limit file paths in repository, changes in which will result in creation of
-                            new freight. When not specified - changes in any path will produce new
-                            freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
-                            When both IncludePaths and ExcludePaths are specified and match same path/paths,
-                            ExcludePaths will prevail over IncludePaths.
-                            Filter strings can be specified as prefix/exact match strings, globs or regular
-                            expressions, default being prefix/exact match, for example "helm/charts/", for
-                            regular expressions strings need to be prepended by either "regex:" or "regexp:"
-                            as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
+                            IncludePaths is a list of selectors that designate paths in the repository
+                            that should trigger the production of new Freight when changes are detected
+                            therein. When specified, only changes in the identified paths will trigger
+                            Freight production. When not specified, changes in any path will trigger
+                            Freight production. Selectors may be defined using:
+                              1. Exact paths to files or directories (ex. "charts/foo")
+                              2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+                              3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+                                 ex. "regexp:^.*\.yaml$")
+                            Paths selected by IncludePaths may be unselected by ExcludePaths. This
+                            is a useful method for including a broad set of paths and then excluding a
+                            subset of them.
                           items:
                             type: string
                           type: array
diff --git a/docs/docs/15-concepts.md b/docs/docs/15-concepts.md
index 0d0c2e82..19d8cdbd 100644
--- a/docs/docs/15-concepts.md
+++ b/docs/docs/15-concepts.md
@@ -557,24 +557,22 @@ spec:
 Kargo uses [semver](https://github.com/masterminds/semver#checking-version-constraints) to handle semantic versioning constraints.
 :::
 
-#### Git Subscription Filtering
+#### Git Subscription Path Filtering
 
-In some cases, it may be necessary to filter the artifacts that a `Warehouse`
-subscribes to. For example, a `Warehouse` may subscribe to a Git repository but
-only want to produce new `Freight` when a commit contains changes to a specific
-directory.
+In some cases, it may be necessary to constrain the paths within a Git
+repository that a `Warehouse` will consider as triggers for `Freight`
+production. This is especially useful for GitOps repositories that are
+"monorepos" containing configuration for multiple applications.
 
-To accomplish this, a `Warehouse` resource's `spec.subscriptions.git` field may
-include an `includePaths` and/or `excludePaths` field with a list of regular
-expressions.
+The paths that may or must not trigger `Freight` production may be specified
+using a combination of the `includePaths` and `excludePaths` fields of a Git
+repository subscription.
 
-When these fields are present, the `Warehouse` will only produce a new `Freight`
-when a commit contains changes to a file in a directory that matches one of the
-`includePaths` and does not match any of the `excludePaths`.
-
-The following example shows a `Warehouse` resource that subscribes to a Git
-repository but only produces new `Freight` when a commit contains changes to
-the `apps/guestbook` directory:
+The following example demonstrates a `Warehouse` with a Git repository
+subscription that will only produce new `Freight` when the latest commit
+(selected by the applicable commit selection strategy) contains changes in the
+`apps/guestbook` directory since the last piece of `Freight` produced by the
+`Warehouse`:
 
 ```yaml
 apiVersion: kargo.akuity.io/v1alpha1
@@ -587,12 +585,13 @@ spec:
   - git:
       repoURL: https://github.com/example/kargo-demo.git
       includePaths:
-      - regex:^apps/guestbook/.*$
+      - apps/guestbook
 ```
 
-The next example shows a `Warehouse` resource that subscribes to a Git repository
-but only produces new `Freight` when a commit contains changes to the `apps/guestbook`
-while excluding the `apps/guestbook/README.md` file:
+The next example demonstrates the opposite: a `Warehouse` with a Git repository
+subscription that will only produce new `Freight` when the latest commit
+(selected by the applicable commit selection strategy) contains changes to paths
+_other than_ the repository's `docs/` directory:
 
 ```yaml
 apiVersion: kargo.akuity.io/v1alpha1
@@ -604,16 +603,57 @@ spec:
     subscriptions:
     - git:
         repoURL: https://github.com/example/kargo-demo.git
+      excludePaths:
+      - docs
+```
+
+`includePaths` and `excludePaths` may be combined to include a broad set of
+paths and then exclude a subset of those. The following example demonstrates a
+`Warehouse` with a Git repository subscription that will only produce new
+`Freight` when the latest commit (selected by the applicable commit selection
+strategy) contains changes _within_ the `apps/guestbook` directory _other than_
+the `apps/guestbook/README.md`:
+
+```yaml
+apiVersion: kargo.akuity.io/v1alpha1
+kind: Warehouse
+metadata:
+  name: my-warehouse
+  namespace: kargo-demo
+spec:
+  subscriptions:
+  - git:
+      repoURL: https://github.com/example/kargo-demo.git
       includePaths:
-      - regex:^apps/guestbook/.*$
+      - apps/guestbook
       excludePaths:
-      - regex:^apps/guestbook/README\.md$
+      - apps/guestbook/README.md
 ```
 
+:::note
+It is important to understand that new `Freight` will be produced when the
+latest commit (selected by the applicable commit selection strategy) contains
+_even a single change_ that is:
+
+1. Implicitly included via undefined `includePaths`.
+
+   &nbsp;&nbsp;&nbsp;&nbsp;OR
+
+   Explicitly included via `includePaths`.
+
+   AND
+
+2. Not explicitly excluded via `excludePaths`.
+:::
+
 :::info
-Note the requirement of the `regex:` (or `regexp:`) prefix on all regular expressions listed in the `includePaths` or `excludePaths` fields.
+By default, the strings in the `includePaths` and `excludePaths` fields are
+treated as exact paths to files or directories. (Selecting a directory will
+implicitly select all paths within that directory.)
 
-This requirement exists so that when support for other, familiar path matching strategies is added in the future, Kargo cannot misinterpret the `*` character. If not for this safeguard, a user expressing desired paths as `example/*`, for instance, could be surprised to find it matches `my-examples/unwanted/file`.
+Paths may _also_ be specified using glob patterns (by prefixing the string with
+`glob:`) or regular expressions (by prefixing the string with `regex:` or
+`regexp:`).
 :::
 
 ### `Promotion` Resources
diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1.go b/internal/api/promote_to_stage_subscribers_v1alpha1.go
index 3b84143d..08ef153a 100644
--- a/internal/api/promote_to_stage_subscribers_v1alpha1.go
+++ b/internal/api/promote_to_stage_subscribers_v1alpha1.go
@@ -133,7 +133,7 @@ func (s *server) PromoteToStageSubscribers(
 	promoteErrs := make([]error, 0, len(subscribers))
 	createdPromos := make([]*kargoapi.Promotion, 0, len(subscribers))
 	for _, subscriber := range subscribers {
-		newPromo := kargo.NewPromotion(subscriber, freight.Name)
+		newPromo := kargo.NewPromotion(ctx, subscriber, freight.Name)
 		if subscriber.Spec.PromotionMechanisms == nil {
 			// Avoid creating a Promotion if the subscriber has no
 			// PromotionMechanisms, and is a "control flow" Stage.
diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go
index 5b5580d0..e22e9960 100644
--- a/internal/api/promote_to_stage_v1alpha1.go
+++ b/internal/api/promote_to_stage_v1alpha1.go
@@ -118,7 +118,7 @@ func (s *server) PromoteToStage(
 		return nil, err
 	}
 
-	promotion := kargo.NewPromotion(*stage, freight.Name)
+	promotion := kargo.NewPromotion(ctx, *stage, freight.Name)
 	if err := s.createPromotionFn(ctx, &promotion); err != nil {
 		return nil, fmt.Errorf("create promotion: %w", err)
 	}
diff --git a/internal/controller/stages/stages.go b/internal/controller/stages/stages.go
index 2e7f4e06..a63ab8ef 100644
--- a/internal/controller/stages/stages.go
+++ b/internal/controller/stages/stages.go
@@ -989,7 +989,7 @@ func (r *reconciler) syncNormalStage(
 
 	logger.Debug("auto-promotion will proceed")
 
-	promo := kargo.NewPromotion(*stage, latestFreight.Name)
+	promo := kargo.NewPromotion(ctx, *stage, latestFreight.Name)
 	if err :=
 		r.createPromotionFn(ctx, &promo); err != nil {
 		return status, fmt.Errorf(
diff --git a/internal/kargo/kargo.go b/internal/kargo/kargo.go
index 68c32dd1..4824a061 100644
--- a/internal/kargo/kargo.go
+++ b/internal/kargo/kargo.go
@@ -1,6 +1,7 @@
 package kargo
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
@@ -11,6 +12,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/user"
 )
 
 const (
@@ -22,7 +24,11 @@ const (
 
 // NewPromotion returns a new Promotion from a given stage and freight with our
 // naming convention.
-func NewPromotion(stage kargoapi.Stage, freight string) kargoapi.Promotion {
+func NewPromotion(
+	ctx context.Context,
+	stage kargoapi.Stage,
+	freight string,
+) kargoapi.Promotion {
 	shortHash := freight
 	if len(shortHash) > 7 {
 		shortHash = freight[0:7]
@@ -32,14 +38,21 @@ func NewPromotion(stage kargoapi.Stage, freight string) kargoapi.Promotion {
 		shortStageName = shortStageName[0:maxStageNamePrefixLength]
 	}
 
+	annotations := make(map[string]string, 1)
+	// Put actor information to track on the controller side
+	if u, ok := user.InfoFromContext(ctx); ok {
+		annotations[kargoapi.AnnotationKeyCreateActor] = kargoapi.FormatEventUserActor(u)
+	}
+
 	// ulid.Make() is pseudo-random, not crypto-random, but we don't care.
 	// We just want a unique ID that can be sorted lexicographically
 	promoName := strings.ToLower(fmt.Sprintf("%s.%s.%s", shortStageName, ulid.Make(), shortHash))
 
 	promotion := kargoapi.Promotion{
 		ObjectMeta: metav1.ObjectMeta{
-			Name:      promoName,
-			Namespace: stage.Namespace,
+			Name:        promoName,
+			Namespace:   stage.Namespace,
+			Annotations: annotations,
 		},
 		Spec: &kargoapi.PromotionSpec{
 			Stage:   stage.Name,
diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/promotion/webhook.go
index 579f390a..69b3c174 100644
--- a/internal/webhook/promotion/webhook.go
+++ b/internal/webhook/promotion/webhook.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 
+	admissionv1 "k8s.io/api/admission/v1"
 	authzv1 "k8s.io/api/authorization/v1"
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -35,7 +36,8 @@ var (
 )
 
 type webhook struct {
-	client client.Client
+	client  client.Client
+	decoder *admission.Decoder
 
 	recorder record.EventRecorder
 
@@ -85,6 +87,7 @@ func SetupWebhookWithManager(
 	w := newWebhook(
 		cfg,
 		mgr.GetClient(),
+		admission.NewDecoder(mgr.GetScheme()),
 		libEvent.NewRecorder(ctx, mgr.GetScheme(), mgr.GetClient(), "promotion-webhook"),
 	)
 	return ctrl.NewWebhookManagedBy(mgr).
@@ -97,10 +100,12 @@ func SetupWebhookWithManager(
 func newWebhook(
 	cfg libWebhook.Config,
 	kubeClient client.Client,
+	decoder *admission.Decoder,
 	recorder record.EventRecorder,
 ) *webhook {
 	w := &webhook{
 		client:   kubeClient,
+		decoder:  decoder,
 		recorder: recorder,
 	}
 	w.getFreightFn = kargoapi.GetFreight
@@ -115,7 +120,40 @@ func newWebhook(
 }
 
 func (w *webhook) Default(ctx context.Context, obj runtime.Object) error {
+	req, err := w.admissionRequestFromContextFn(ctx)
+	if err != nil {
+		return fmt.Errorf("get admission request from context: %w", err)
+	}
+
 	promo := obj.(*kargoapi.Promotion) // nolint: forcetypeassert
+	var oldPromo *kargoapi.Promotion
+	// We need to decode old object manually since controller-runtime doesn't decode it for us.
+	if req.Operation == admissionv1.Update {
+		oldPromo = &kargoapi.Promotion{}
+		if err = w.decoder.DecodeRaw(req.OldObject, oldPromo); err != nil {
+			return fmt.Errorf("decode old object: %w", err)
+		}
+	}
+
+	if promo.Annotations == nil {
+		promo.Annotations = make(map[string]string, 1)
+	}
+	if req.Operation == admissionv1.Create {
+		// Set actor as an admission request's user info when the promotion is created
+		// to allow controllers to track who created it.
+		if !w.isRequestFromKargoControlplaneFn(req) {
+			promo.Annotations[kargoapi.AnnotationKeyCreateActor] =
+				kargoapi.FormatEventKubernetesUserActor(req.UserInfo)
+		}
+	} else if req.Operation == admissionv1.Update {
+		// Ensure actor annotation immutability
+		if oldActor, ok := oldPromo.Annotations[kargoapi.AnnotationKeyCreateActor]; ok {
+			promo.Annotations[kargoapi.AnnotationKeyCreateActor] = oldActor
+		} else {
+			delete(promo.Annotations, kargoapi.AnnotationKeyCreateActor)
+		}
+	}
+
 	stage, err := w.getStageFn(
 		ctx,
 		w.client,
diff --git a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
index b5d8816f..80f86cff 100644
--- a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
@@ -73,7 +73,7 @@
                     "type": "string"
                   },
                   "excludePaths": {
-                    "description": "ExcludePaths is an optional list of filter strings used to specify paths\nin git repository, changes in which should never produce new freight. When used\nin conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes\nprecedence over IncludePaths.\nFilter strings can be specified as prefix/exact match strings, globs or regular\nexpressions, default being prefix/exact match, for example \"helm/charts/\", for\nregular expressions strings need to be prepended by either \"regex:\" or \"regexp:\"\nas in \"regexp:^.*values\\.yaml$\", for globs with \"glob:\" as in \"glob:helm/*/*/values.*ml\"",
+                    "description": "ExcludePaths is a list of selectors that designate paths in the repository\nthat should NOT trigger the production of new Freight when changes are\ndetected therein. When specified, changes in the identified paths will not\ntrigger Freight production. When not specified, paths that should trigger\nFreight production will be defined solely by IncludePaths. Selectors may be\ndefined using:\n  1. Exact paths to files or directories (ex. \"charts/foo\")\n  2. Glob patterns (prefix the pattern with \"glob:\"; ex. \"glob:*.yaml\")\n  3. Regular expressions (prefix the pattern with \"regex:\" or \"regexp:\";\n     ex. \"regexp:^.*\\.yaml$\")\nPaths selected by IncludePaths may be unselected by ExcludePaths. This\nis a useful method for including a broad set of paths and then excluding a\nsubset of them.",
                     "items": {
                       "type": "string"
                     },
@@ -87,7 +87,7 @@
                     "type": "array"
                   },
                   "includePaths": {
-                    "description": "IncludePaths is a list of filter strings that can optionally be used to\nlimit file paths in repository, changes in which will result in creation of\nnew freight. When not specified - changes in any path will produce new\nfreight, it is equivalent to having a IncludePaths with an entry of \"regex:.*\"\nWhen both IncludePaths and ExcludePaths are specified and match same path/paths,\nExcludePaths will prevail over IncludePaths.\nFilter strings can be specified as prefix/exact match strings, globs or regular\nexpressions, default being prefix/exact match, for example \"helm/charts/\", for\nregular expressions strings need to be prepended by either \"regex:\" or \"regexp:\"\nas in \"regexp:^.*values\\.yaml$\", for globs with \"glob:\" as in \"glob:helm/*/*/values.*ml\"",
+                    "description": "IncludePaths is a list of selectors that designate paths in the repository\nthat should trigger the production of new Freight when changes are detected\ntherein. When specified, only changes in the identified paths will trigger\nFreight production. When not specified, changes in any path will trigger\nFreight production. Selectors may be defined using:\n  1. Exact paths to files or directories (ex. \"charts/foo\")\n  2. Glob patterns (prefix the pattern with \"glob:\"; ex. \"glob:*.yaml\")\n  3. Regular expressions (prefix the pattern with \"regex:\" or \"regexp:\";\n     ex. \"regexp:^.*\\.yaml$\")\nPaths selected by IncludePaths may be unselected by ExcludePaths. This\nis a useful method for including a broad set of paths and then excluding a\nsubset of them.",
                     "items": {
                       "type": "string"
                     },
diff --git a/ui/src/gen/v1alpha1/generated_pb.ts b/ui/src/gen/v1alpha1/generated_pb.ts
index 4a724d57..671d6116 100644
--- a/ui/src/gen/v1alpha1/generated_pb.ts
+++ b/ui/src/gen/v1alpha1/generated_pb.ts
@@ -1592,16 +1592,18 @@ export class GitSubscription extends Message<GitSubscription> {
   insecureSkipTLSVerify?: boolean;
 
   /**
-   * IncludePaths is a list of filter strings that can optionally be used to
-   * limit file paths in repository, changes in which will result in creation of
-   * new freight. When not specified - changes in any path will produce new
-   * freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
-   * When both IncludePaths and ExcludePaths are specified and match same path/paths,
-   * ExcludePaths will prevail over IncludePaths.
-   * Filter strings can be specified as prefix/exact match strings, globs or regular
-   * expressions, default being prefix/exact match, for example "helm/charts/", for
-   * regular expressions strings need to be prepended by either "regex:" or "regexp:"
-   * as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*\/*\/values.*ml"
+   * IncludePaths is a list of selectors that designate paths in the repository
+   * that should trigger the production of new Freight when changes are detected
+   * therein. When specified, only changes in the identified paths will trigger
+   * Freight production. When not specified, changes in any path will trigger
+   * Freight production. Selectors may be defined using:
+   *   1. Exact paths to files or directories (ex. "charts/foo")
+   *   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+   *   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+   *      ex. "regexp:^.*\.yaml$")
+   * Paths selected by IncludePaths may be unselected by ExcludePaths. This
+   * is a useful method for including a broad set of paths and then excluding a
+   * subset of them.
    * +kubebuilder:validation:Optional
    *
    * @generated from field: repeated string includePaths = 8;
@@ -1609,14 +1611,19 @@ export class GitSubscription extends Message<GitSubscription> {
   includePaths: string[] = [];
 
   /**
-   * ExcludePaths is an optional list of filter strings used to specify paths
-   * in git repository, changes in which should never produce new freight. When used
-   * in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
-   * precedence over IncludePaths.
-   * Filter strings can be specified as prefix/exact match strings, globs or regular
-   * expressions, default being prefix/exact match, for example "helm/charts/", for
-   * regular expressions strings need to be prepended by either "regex:" or "regexp:"
-   * as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*\/*\/values.*ml"
+   * ExcludePaths is a list of selectors that designate paths in the repository
+   * that should NOT trigger the production of new Freight when changes are
+   * detected therein. When specified, changes in the identified paths will not
+   * trigger Freight production. When not specified, paths that should trigger
+   * Freight production will be defined solely by IncludePaths. Selectors may be
+   * defined using:
+   *   1. Exact paths to files or directories (ex. "charts/foo")
+   *   2. Glob patterns (prefix the pattern with "glob:"; ex. "glob:*.yaml")
+   *   3. Regular expressions (prefix the pattern with "regex:" or "regexp:";
+   *      ex. "regexp:^.*\.yaml$")
+   * Paths selected by IncludePaths may be unselected by ExcludePaths. This
+   * is a useful method for including a broad set of paths and then excluding a
+   * subset of them.
    * +kubebuilder:validation:Optional
    *
    * @generated from field: repeated string excludePaths = 9;


Test patch:
diff --git a/internal/kargo/kargo_test.go b/internal/kargo/kargo_test.go
index 7ef06e47..cca70658 100644
--- a/internal/kargo/kargo_test.go
+++ b/internal/kargo/kargo_test.go
@@ -1,6 +1,7 @@
 package kargo
 
 import (
+	"context"
 	"strings"
 	"testing"
 
@@ -64,7 +65,7 @@ func TestNewPromotion(t *testing.T) {
 	for _, tc := range testCases {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
-			promo := NewPromotion(tc.stage, tc.freight)
+			promo := NewPromotion(context.TODO(), tc.stage, tc.freight)
 			require.Equal(t, tc.freight, promo.Spec.Freight)
 			require.Equal(t, tc.stage.Name, promo.Spec.Stage)
 			require.Equal(t, tc.freight, promo.Spec.Freight)
diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/promotion/webhook_test.go
index 33479792..cf7e66f3 100644
--- a/internal/webhook/promotion/webhook_test.go
+++ b/internal/webhook/promotion/webhook_test.go
@@ -27,6 +27,7 @@ func TestNewWebhook(t *testing.T) {
 	w := newWebhook(
 		libWebhook.Config{},
 		kubeClient,
+		admission.NewDecoder(kubeClient.Scheme()),
 		&fakeevent.EventRecorder{},
 	)
 	// Assert that all overridable behaviors were initialized to a default:
@@ -48,6 +49,9 @@ func TestDefault(t *testing.T) {
 		{
 			name: "error getting stage",
 			webhook: &webhook{
+				admissionRequestFromContextFn: func(context.Context) (admission.Request, error) {
+					return admission.Request{}, nil
+				},
 				getStageFn: func(
 					context.Context,
 					client.Client,
@@ -64,6 +68,9 @@ func TestDefault(t *testing.T) {
 		{
 			name: "stage not found",
 			webhook: &webhook{
+				admissionRequestFromContextFn: func(context.Context) (admission.Request, error) {
+					return admission.Request{}, nil
+				},
 				getStageFn: func(
 					context.Context,
 					client.Client,
@@ -80,6 +87,9 @@ func TestDefault(t *testing.T) {
 		{
 			name: "stage without promotion mechanisms",
 			webhook: &webhook{
+				admissionRequestFromContextFn: func(context.Context) (admission.Request, error) {
+					return admission.Request{}, nil
+				},
 				getStageFn: func(
 					context.Context,
 					client.Client,
@@ -98,6 +108,9 @@ func TestDefault(t *testing.T) {
 		{
 			name: "success",
 			webhook: &webhook{
+				admissionRequestFromContextFn: func(context.Context) (admission.Request, error) {
+					return admission.Request{}, nil
+				},
 				getStageFn: func(
 					context.Context,
 					client.Client,