Repository: akuity/kargo
Pull request #1919 (2024-05-01 13:32:28)

refactor(controller): avoid patching Stage from Promotions reconciler
This pull request changes the way Stages keep track of their current Freight, by removing the patching of the Stage from the Promotion reconciler and replacing it with logic to allow the Stage itself to self-determine what its current Freight is based on the Promotions existing for it.

From a bird's-eye view, it performs the following steps during the reconciliation of a Stage:

1. Retrieve all Promotions associated with the Stage.
2. Arrange them according to specific criteria:
   - Prioritize Running promotions, further sorted by their ULID in ascending order.
   - Next consider other non-terminal promotions, further sorted by their ULID in ascending order.
   - Finally, consider terminal promotions, sorted by their ULID in descending order.
3. Check if the first Promotion in the ordered list is non-terminal. If so, it signifies that the Stage is in a Promoting phase, and the CurrentPromotion must reflect this. If not, CurrentPromotion remains empty, indicating that the Stage is in a Steady phase.
4. Identify any newly terminated Promotions since the last reconciliation by comparing their ULID with the LastPromotion information of the Stage. If created after LastPromotion, they are considered new.
5. Proceed through the list of newly terminated Promotions from oldest to newest. Update the LastPromotion information for the Stage accordingly. If the Promotion was successful, update the Freight history and CurrentFreight for the Stage to reflect this Promotion.

In addition to this:

- The logic around when the Stage is marked as Verifying has changed. This is now set at the point where the reconciler notices the CurrentFreight does not have any VerificationInfo and the current phase is Steady (and _not_ e.g. Promoting).
- The health checks for the Stage will run regardless of any phase of the Stage (and there being e.g. a Promotion in a Running phase).

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


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | d701be250052118239a3658554ca9505db33aaf0 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/6632446e69af8d0008ee0ad1 |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1919?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 `85.95041%` with `17 lines` in your changes are missing coverage. Please review.
> Project coverage is 45.92%. Comparing base [(`af4feb5`)](https://app.codecov.io/gh/akuity/kargo/commit/af4feb521ac83a95290a0a49c65acc49be8cb33f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`8fbc1c2`)](https://app.codecov.io/gh/akuity/kargo/pull/1919?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).
> Report is 4 commits behind head on main.

| [Files](https://app.codecov.io/gh/akuity/kargo/pull/1919?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/controller/promotions/promotions.go](https://app.codecov.io/gh/akuity/kargo/pull/1919?src=pr&el=tree&filepath=internal%2Fcontroller%2Fpromotions%2Fpromotions.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY29udHJvbGxlci9wcm9tb3Rpb25zL3Byb21vdGlvbnMuZ28=) | 0.00% | [12 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1919?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/controller/stages/stages.go](https://app.codecov.io/gh/akuity/kargo/pull/1919?src=pr&el=tree&filepath=internal%2Fcontroller%2Fstages%2Fstages.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY29udHJvbGxlci9zdGFnZXMvc3RhZ2VzLmdv) | 95.41% | [3 Missing and 2 partials :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1919?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    #1919      +/-   ##
==========================================
+ Coverage   45.58%   45.92%   +0.33%     
==========================================
  Files         235      235              
  Lines       15793    15864      +71     
==========================================
+ Hits         7199     7285      +86     
+ Misses       8240     8221      -19     
- Partials      354      358       +4     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1919?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: @hiddeco do you think we should remove the now-unused `PromoWentTerminal` predicate?
hiddeco: It is still being used by the queue for Promotions itself.
krancour: > It is still being used by the queue for Promotions itself.

Ah. I see it now. 👍 

Patch:
diff --git a/api/v1alpha1/stage_types.go b/api/v1alpha1/stage_types.go
index 4267cddd..2d1b6a2a 100644
--- a/api/v1alpha1/stage_types.go
+++ b/api/v1alpha1/stage_types.go
@@ -556,9 +556,16 @@ type FreightReference struct {
 
 type FreightReferenceStack []FreightReference
 
-// UpdateOrPush updates the FreightReference with the same name as the provided
-// FreightReference or appends the provided FreightReference to the stack if no
-// such FreightReference is found.
+// Push appends the provided FreightReference to the top of the stack. If the
+// stack grows beyond 10 items, the bottom items are removed.
+func (f *FreightReferenceStack) Push(freight ...FreightReference) {
+	*f = append(freight, *f...)
+	f.truncate()
+}
+
+// UpdateOrPush updates the first FreightReference with the same name as the
+// provided FreightReference or appends the provided FreightReference to the
+// stack if no such FreightReference is found.
 //
 // The order of existing items in the stack is preserved, and new items without
 // a matching name are appended to the top of the stack. If the stack grows
@@ -580,7 +587,10 @@ func (f *FreightReferenceStack) UpdateOrPush(freight ...FreightReference) {
 	}
 
 	*f = append(newStack, *f...)
+	f.truncate()
+}
 
+func (f *FreightReferenceStack) truncate() {
 	const maxSize = 10
 	if len(*f) > maxSize {
 		*f = (*f)[:maxSize]
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 61371271..76667b11 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -445,16 +445,6 @@ func (r *reconciler) promote(
 		Charts:    targetFreight.Charts,
 		Warehouse: targetFreight.Warehouse,
 	}
-	err = kubeclient.PatchStatus(ctx, r.kargoClient, stage, func(status *kargoapi.StageStatus) {
-		status.Phase = kargoapi.StagePhasePromoting
-		status.CurrentPromotion = &kargoapi.PromotionInfo{
-			Name:    promo.Name,
-			Freight: targetFreightRef,
-		}
-	})
-	if err != nil {
-		return nil, err
-	}
 
 	newStatus, nextFreight, err := r.promoMechanisms.Promote(ctx, stage, &promo, targetFreightRef)
 	if err != nil {
@@ -464,46 +454,23 @@ func (r *reconciler) promote(
 
 	logger.Debug("promotion", "phase", newStatus.Phase)
 
-	if newStatus.Phase.IsTerminal() {
-		// The assumption is that controller does not process multiple promotions in one stage
-		// so we are safe from race conditions and can just update the status
-		// TODO: remove all patching of Stage status out of promo reconciler
-		if err = kubeclient.PatchStatus(ctx, r.kargoClient, stage, func(status *kargoapi.StageStatus) {
-			status.LastPromotion = status.CurrentPromotion
-			status.LastPromotion.Status = newStatus
-			if newStatus.Phase == kargoapi.PromotionPhaseSucceeded {
-				// Handle specific things that need to happen on success.
-				// 1. Trigger re-verification for re-promotions.
-				// 2. Otherwise, update the current freight and history.
-				// 3. Update the phase to Verifying and clear the current promotion.
-				if status.CurrentFreight != nil &&
-					status.CurrentFreight.Name == targetFreight.Name {
-					if err = kargoapi.ReverifyStageFreight(
-						ctx,
-						r.kargoClient,
-						types.NamespacedName{
-							Namespace: stageNamespace,
-							Name:      stageName,
-						},
-					); err != nil {
-						// Log the error, but don't let failure to initiate re-verification
-						// prevent the promotion from succeeding.
-						logger.Error(err, "error triggering re-verification")
-					}
-				} else if stage.Spec.PromotionMechanisms != nil {
-					status.CurrentFreight = &nextFreight
-					status.History.UpdateOrPush(nextFreight)
-				}
-				status.Phase = kargoapi.StagePhaseVerifying
-				status.CurrentPromotion = nil
+	if newStatus.Phase == kargoapi.PromotionPhaseSucceeded {
+		// Trigger re-verification of the Stage if the promotion succeeded and
+		// this is a re-promotion of the same Freight.
+		curFreight := stage.Status.CurrentFreight
+		if curFreight != nil && curFreight.Name == targetFreight.Name && curFreight.VerificationInfo != nil {
+			if err = kargoapi.ReverifyStageFreight(
+				ctx,
+				r.kargoClient,
+				types.NamespacedName{
+					Namespace: stageNamespace,
+					Name:      stageName,
+				},
+			); err != nil {
+				// Log the error, but don't let failure to initiate re-verification
+				// prevent the promotion from succeeding.
+				logger.Error(err, "error triggering re-verification")
 			}
-		}); err != nil {
-			return nil, fmt.Errorf(
-				"error updating status of Stage %q in namespace %q: %w",
-				stageName,
-				stageNamespace,
-				err,
-			)
 		}
 	}
 
diff --git a/internal/controller/stages/stages.go b/internal/controller/stages/stages.go
index 9b10d951..4ef3bcc4 100644
--- a/internal/controller/stages/stages.go
+++ b/internal/controller/stages/stages.go
@@ -3,7 +3,9 @@ package stages
 import (
 	"context"
 	"fmt"
+	"slices"
 	"sort"
+	"strings"
 	"time"
 
 	"github.com/kelseyhightower/envconfig"
@@ -66,15 +68,21 @@ type reconciler struct {
 
 	// The following behaviors are overridable for testing purposes:
 
-	// Loop guard:
+	// Promotion-related:
 
 	nowFn func() time.Time
 
-	hasNonTerminalPromotionsFn func(
-		ctx context.Context,
-		stageNamespace string,
-		stageName string,
-	) (bool, error)
+	syncPromotionsFn func(
+		context.Context,
+		*kargoapi.Stage,
+		kargoapi.StageStatus,
+	) (kargoapi.StageStatus, error)
+
+	getPromotionsForStageFn func(
+		context.Context,
+		string,
+		string,
+	) ([]kargoapi.Promotion, error)
 
 	listPromosFn func(
 		context.Context,
@@ -236,8 +244,8 @@ func SetupReconcilerWithManager(
 	argocdMgr manager.Manager,
 	cfg ReconcilerConfig,
 ) error {
-	// Index Promotions in non-terminal states by Stage
-	if err := kubeclient.IndexNonTerminalPromotionsByStage(ctx, kargoMgr); err != nil {
+	// Index Promotions by Stage
+	if err := kubeclient.IndexPromotionsByStage(ctx, kargoMgr); err != nil {
 		return fmt.Errorf("index non-terminal Promotions by Stage: %w", err)
 	}
 
@@ -335,20 +343,20 @@ func SetupReconcilerWithManager(
 	}
 
 	logger := logging.LoggerFromContext(ctx)
-	// Watch Promotions that completed and enqueue owning Stage key
+	// Watch Promotions for which the phase changed and enqueue owning Stage key
 	promoOwnerHandler := handler.TypedEnqueueRequestForOwner[*kargoapi.Promotion](
 		kargoMgr.GetScheme(),
 		kargoMgr.GetRESTMapper(),
 		&kargoapi.Stage{},
 		handler.OnlyControllerOwner(),
 	)
-	promoWentTerminal := kargo.NewPromoWentTerminalPredicate(logger)
-	if err := c.Watch(
+	promoPhaseChanged := kargo.NewPromoPhaseChangedPredicate(logger)
+	if err = c.Watch(
 		source.Kind(
 			kargoMgr.GetCache(),
 			&kargoapi.Promotion{},
 			promoOwnerHandler,
-			promoWentTerminal,
+			promoPhaseChanged,
 		),
 	); err != nil {
 		return fmt.Errorf("unable to watch Promotions: %w", err)
@@ -451,10 +459,11 @@ func newReconciler(
 		shardRequirement: shardRequirement,
 	}
 	// The following default behaviors are overridable for testing purposes:
-	// Loop guard:
+	// Promotion-related:
 	r.nowFn = time.Now
-	r.hasNonTerminalPromotionsFn = r.hasNonTerminalPromotions
+	r.syncPromotionsFn = r.syncPromotions
 	r.listPromosFn = r.kargoClient.List
+	r.getPromotionsForStageFn = r.getPromotionsForStage
 	// Freight verification:
 	r.startVerificationFn = r.startVerification
 	r.abortVerificationFn = r.abortVerification
@@ -688,27 +697,21 @@ func (r *reconciler) syncNormalStage(
 
 	logger := logging.LoggerFromContext(ctx)
 
-	// Skip the entire reconciliation loop if there are Promotions associate with
-	// this Stage in a non-terminal state. The promotion process and this
-	// reconciliation loop BOTH update Stage status, so this check helps us
-	// to avoid race conditions that may otherwise arise.
-	if hasNonTerminalPromos, err := r.hasNonTerminalPromotionsFn(
-		ctx,
-		stage.Namespace,
-		stage.Name,
-	); err != nil {
+	// Sync Promotions and update the Stage status.
+	var syncErr error
+	if status, syncErr = r.syncPromotionsFn(ctx, stage, status); syncErr != nil {
+		return status, syncErr
+	}
+	if err := kubeclient.PatchStatus(ctx, r.kargoClient, stage, func(s *kargoapi.StageStatus) {
+		*s = status
+	}); err != nil {
 		return status, err
-	} else if hasNonTerminalPromos {
-		logger.Debug(
-			"Stage has one or more Promotions in a non-terminal phase; skipping " +
-				"this reconciliation loop",
-		)
-		return status, nil
 	}
 
+	// Take note of the current Generation of the Stage as being observed,
+	// and reset the health status.
 	status.ObservedGeneration = stage.Generation
-	status.Health = nil // Reset health
-	status.CurrentPromotion = nil
+	status.Health = nil
 
 	if status.CurrentFreight == nil {
 		status.Phase = kargoapi.StagePhaseNotApplicable
@@ -725,26 +728,19 @@ func (r *reconciler) syncNormalStage(
 			status.History.UpdateOrPush(*status.CurrentFreight)
 		}()
 
-		// Check health
+		// Always check the health of the Argo CD Applications associated with the
+		// Stage. This is regardless of the phase of the Stage, as the health of the
+		// Argo CD Applications is always relevant.
 		if status.Health = r.appHealth.EvaluateHealth(
 			ctx,
 			*status.CurrentFreight,
 			stage.Spec.PromotionMechanisms.ArgoCDAppUpdates,
 		); status.Health != nil {
-			freightLogger.WithValues("health", status.Health.Status).
-				Debug("Stage health assessed")
+			freightLogger.WithValues("health", status.Health.Status).Debug("Stage health assessed")
 		} else {
 			freightLogger.Debug("Stage health deemed not applicable")
 		}
 
-		// If the Stage is healthy and no verification process is defined, then the
-		// Stage should transition to the Steady phase.
-		if (status.Health == nil || status.Health.Status == kargoapi.HealthStateHealthy) &&
-			stage.Spec.Verification == nil && status.Phase == kargoapi.StagePhaseVerifying {
-			status.Phase = kargoapi.StagePhaseSteady
-		}
-
-		// Initiate or follow-up on verification if required
 		if stage.Spec.Verification != nil {
 			// Update the verification history with the current verification info.
 			// NOTE: We do this regardless of the phase of the verification process
@@ -755,22 +751,24 @@ func (r *reconciler) syncNormalStage(
 				status.CurrentFreight.VerificationHistory.UpdateOrPush(*status.CurrentFreight.VerificationInfo)
 			}
 
-			// Confirm if a reverification is requested. If so, clear the
-			// verification info to start the verification process again.
-			info := status.CurrentFreight.VerificationInfo
-			if info != nil && info.Phase.IsTerminal() {
-				if req, _ := kargoapi.ReverifyAnnotationValue(stage.GetAnnotations()); req.ForID(info.ID) {
-					logger.Debug("rerunning verification")
+			// If the Stage is in a steady state, we should check if we need to
+			// start or rerun verification.
+			if status.Phase == kargoapi.StagePhaseSteady {
+				info := status.CurrentFreight.VerificationInfo
+				switch {
+				case info == nil && status.CurrentPromotion == nil:
 					status.Phase = kargoapi.StagePhaseVerifying
-					status.CurrentFreight.VerificationInfo = nil
+				case info.Phase.IsTerminal():
+					if req, _ := kargoapi.ReverifyAnnotationValue(stage.GetAnnotations()); req.ForID(info.ID) {
+						logger.Debug("rerunning verification")
+						status.Phase = kargoapi.StagePhaseVerifying
+						status.CurrentFreight.VerificationInfo = nil
+					}
 				}
 			}
 
-			// NOTE: If stage cache is stale, phase can be StagePhaseNotApplicable
-			//       even though current freight is not empty in that case
-			//       check if verification step is necessary and if yes execute
-			//       step irrespective of phase
-			if status.Phase == kargoapi.StagePhaseVerifying || status.Phase == kargoapi.StagePhaseNotApplicable {
+			// Initiate or follow-up on verification if required.
+			if status.Phase == kargoapi.StagePhaseVerifying {
 				if !status.CurrentFreight.VerificationInfo.HasAnalysisRun() {
 					if status.Health == nil || status.Health.Status == kargoapi.HealthStateHealthy {
 						logger.Debug("starting verification")
@@ -826,6 +824,11 @@ func (r *reconciler) syncNormalStage(
 					status.CurrentFreight.VerificationHistory.UpdateOrPush(*status.CurrentFreight.VerificationInfo)
 				}
 			}
+		} else {
+			// If verification is not applicable, mark the Stage as steady.
+			// This ensures that if the Stage had verification enabled previously,
+			// it will not be stuck in a verification phase.
+			status.Phase = kargoapi.StagePhaseSteady
 		}
 
 		// If health is not applicable or healthy
@@ -1021,6 +1024,104 @@ func (r *reconciler) syncNormalStage(
 	return status, nil
 }
 
+// syncPromotions determines the current state of the Stage and its Freight by
+// examining the Promotions that have been created for the Stage. It returns the
+// updated Stage status.
+//
+// The Stage is considered to be promoting if the latest Promotion is in a
+// running phase. In this case, the Stage is marked as promoting, and the
+// current Promotion is recorded in the Stage status. If the latest Promotion
+// is not in a running phase, the Stage is considered to be steady.
+//
+// New Promotions that have terminated since the last reconciliation are
+// discovered by comparing a list of terminated Promotions to the last known
+// Promotion. Any newer Promotions found are recorded in the Stage status, and
+// the Freight that was successfully promoted is recorded in the Freight
+// history.
+func (r *reconciler) syncPromotions(
+	ctx context.Context,
+	stage *kargoapi.Stage,
+	status kargoapi.StageStatus,
+) (kargoapi.StageStatus, error) {
+	logger := logging.LoggerFromContext(ctx)
+
+	promotions, err := r.getPromotionsForStageFn(ctx, stage.Namespace, stage.Name)
+	if err != nil || len(promotions) == 0 {
+		return status, err
+	}
+
+	// Sort the Promotions by phase and creation time so that we can determine the
+	// current state of the Stage.
+	slices.SortFunc(promotions, comparePromotionByPhaseAndCreationTime)
+
+	// If the latest Promotion is in a running phase, the Stage is promoting.
+	if latestPromo := promotions[0]; !latestPromo.Status.Phase.IsTerminal() {
+		logger.WithValues("promotion", latestPromo.Name).Debug("Stage has a running Promotion")
+		status.Phase = kargoapi.StagePhasePromoting
+		status.CurrentPromotion = &kargoapi.PromotionInfo{
+			Name: latestPromo.Name,
+		}
+		if latestPromo.Status.Freight != nil {
+			status.CurrentPromotion.Freight = *latestPromo.Status.Freight.DeepCopy()
+		}
+	} else {
+		status.CurrentPromotion = nil
+	}
+
+	// Determine if there are any new Promotions that have been completed since
+	// the last reconciliation.
+	logger.Debug("checking for new terminated Promotions")
+	var newPromotions []kargoapi.PromotionInfo
+	for _, promo := range promotions {
+		if status.LastPromotion != nil {
+			// We can break here since we know that all subsequent Promotions
+			// will be older than the last Promotion we saw.
+			// NB: This makes use of the fact that Promotion names are
+			// generated, and contain a timestamp component which will ensure
+			// that they can be sorted in a consistent order.
+			if strings.Compare(promo.Name, status.LastPromotion.Name) <= 0 {
+				break
+			}
+		}
+
+		if promo.Status.Phase.IsTerminal() {
+			logger.WithValues("promotion", promo.Name).Debug("found new terminated Promotion")
+			info := kargoapi.PromotionInfo{
+				Name:   promo.Name,
+				Status: promo.Status.DeepCopy(),
+			}
+			if promo.Status.Freight != nil {
+				info.Freight = *promo.Status.Freight.DeepCopy()
+			}
+			newPromotions = append(newPromotions, info)
+		}
+	}
+
+	// As we will be appending to the Freight history, we need to ensure that
+	// we order the Promotions from oldest to newest. This is because the
+	// Freight history is garbage collected based on the number of entries,
+	// and we want to ensure that the oldest entries are removed first.
+	slices.SortFunc(newPromotions, func(a, b kargoapi.PromotionInfo) int {
+		return strings.Compare(a.Name, b.Name)
+	})
+
+	// Update the Stage status with the information about the newly terminated
+	// Promotions, and any new Freight that was successfully promoted.
+	for _, p := range newPromotions {
+		promo := p
+		status.LastPromotion = &promo
+		if promo.Status.Phase == kargoapi.PromotionPhaseSucceeded {
+			status.CurrentFreight = status.LastPromotion.Freight.DeepCopy()
+			status.History.Push(status.LastPromotion.Freight)
+			if status.CurrentPromotion == nil {
+				status.Phase = kargoapi.StagePhaseSteady
+			}
+		}
+	}
+
+	return status, nil
+}
+
 func (r *reconciler) syncStageDelete(
 	ctx context.Context,
 	stage *kargoapi.Stage,
@@ -1166,32 +1267,6 @@ func (r *reconciler) clearAnalysisRuns(
 	return nil
 }
 
-func (r *reconciler) hasNonTerminalPromotions(
-	ctx context.Context,
-	stageNamespace string,
-	stageName string,
-) (bool, error) {
-	promos := kargoapi.PromotionList{}
-	if err := r.listPromosFn(
-		ctx,
-		&promos,
-		&client.ListOptions{
-			Namespace: stageNamespace,
-			FieldSelector: fields.Set(map[string]string{
-				kubeclient.NonTerminalPromotionsByStageIndexField: stageName,
-			}).AsSelector(),
-		},
-	); err != nil {
-		return false, fmt.Errorf(
-			"error listing Promotions in non-terminal phases for Stage %q in namespace %q: %w",
-			stageNamespace,
-			stageName,
-			err,
-		)
-	}
-	return len(promos.Items) > 0, nil
-}
-
 // verifyFreightInStage marks the given Freight as verified in the given Stage.
 // It returns true if succeeded to mark Freight as verified in the Stage,
 // or false if it was already marked as verified in the Stage.
@@ -1300,6 +1375,33 @@ func (r *reconciler) isAutoPromotionPermitted(
 	return false, nil
 }
 
+func (r *reconciler) getPromotionsForStage(
+	ctx context.Context,
+	stageNamespace string,
+	stageName string,
+) ([]kargoapi.Promotion, error) {
+	var promos kargoapi.PromotionList
+	if err := r.listPromosFn(
+		ctx,
+		&promos,
+		&client.ListOptions{
+			Namespace: stageNamespace,
+			FieldSelector: fields.OneTermEqualSelector(
+				kubeclient.PromotionsByStageIndexField,
+				stageName,
+			),
+		},
+	); err != nil {
+		return nil, fmt.Errorf(
+			"error listing Promotions for Stage %q in namespace %q: %w",
+			stageName,
+			stageNamespace,
+			err,
+		)
+	}
+	return promos.Items, nil
+}
+
 func (r *reconciler) getLatestAvailableFreight(
 	ctx context.Context,
 	namespace string,
@@ -1571,3 +1673,63 @@ func (r *reconciler) recordFreightVerificationEvent(
 
 	r.recorder.AnnotatedEventf(fr, annotations, corev1.EventTypeNormal, reason, message)
 }
+
+// comparePromotionByPhaseAndCreationTime compares two Promotions by their
+// phase and creation timestamp. It returns a negative value if Promotion `a`
+// should come before Promotion `b`, a positive value if Promotion `a` should
+// come after Promotion `b`, or zero if they are considered equal for sorting
+// purposes.
+//
+// The order of Promotions is as follows:
+//  1. Running, ordered by ULID in ascending order.
+//  2. Terminated, ordered by ULID in ascending order.
+//  3. Non-terminal, ordered by ULID in descending order.
+func comparePromotionByPhaseAndCreationTime(a, b kargoapi.Promotion) int {
+	// Compare the phases of the Promotions first.
+	if phaseCompare := comparePromotionPhase(a.Status.Phase, b.Status.Phase); phaseCompare != 0 {
+		return phaseCompare
+	}
+
+	switch {
+	case !a.Status.Phase.IsTerminal():
+		// Non-terminal Promotions are ordered in ascending order based on the
+		// ULID in the Promotion name. This ensures that the Promotion which
+		// was (or will be) enqueued first is at the top.
+		return strings.Compare(a.Name, b.Name)
+	default:
+		// Terminal Promotions are ordered in descending order based on the
+		// ULID in the Promotion name. This ensures that the most recent
+		// Promotion is at the top, limiting the number of Promotions which
+		// have to be further inspected to collect the "new" Promotions.
+		return strings.Compare(b.Name, a.Name)
+	}
+}
+
+// comparePromotionPhase compares two Promotion phases. It returns a negative
+// value if phase `a` should come before phase `b`, a positive value if phase
+// `a` should come after phase `b`, or zero if they are considered equal for
+// sorting purposes.
+//
+// The order of Promotion phases is as follows:
+//  1. Running
+//  2. Non-terminal phases
+//  3. Terminal phases
+func comparePromotionPhase(a, b kargoapi.PromotionPhase) int {
+	aRunning, bRunning := a == kargoapi.PromotionPhaseRunning, b == kargoapi.PromotionPhaseRunning
+	aTerminal, bTerminal := a.IsTerminal(), b.IsTerminal()
+
+	// NB: The order of the cases here is important, as "Running" is a special
+	// case that should always come before any other phase.
+	switch {
+	case aRunning && !bRunning:
+		return -1
+	case !aRunning && bRunning:
+		return 1
+	case !aTerminal && bTerminal:
+		return -1
+	case aTerminal && !bTerminal:
+		return 1
+	default:
+		return 0
+	}
+}
diff --git a/internal/kargo/kargo.go b/internal/kargo/kargo.go
index dbd8c35a..58d8906d 100644
--- a/internal/kargo/kargo.go
+++ b/internal/kargo/kargo.go
@@ -118,6 +118,58 @@ func (p PromoWentTerminal[T]) Update(e event.TypedUpdateEvent[T]) bool {
 	return false
 }
 
+func NewPromoPhaseChangedPredicate(logger *logging.Logger) PromoPhaseChanged[*kargoapi.Promotion] {
+	return PromoPhaseChanged[*kargoapi.Promotion]{
+		logger: logger,
+	}
+}
+
+// PromoPhaseChanged is a predicate that returns true if the phase of a promotion
+// has changed. It can be used to trigger the reconciliation of an associated
+// object when the phase of a Promotion changes. A concrete example is to trigger
+// the reconciliation of a Stage when the phase of a Promotion for that Stage
+// changes, so that the Stage can update the last Promotion reference in its
+// status.
+type PromoPhaseChanged[T any] struct {
+	predicate.Funcs
+	logger *logging.Logger
+}
+
+func (p PromoPhaseChanged[T]) Create(event.TypedCreateEvent[T]) bool {
+	return false
+}
+
+func (p PromoPhaseChanged[T]) Delete(e event.TypedDeleteEvent[T]) bool {
+	promo := any(e.Object).(*kargoapi.Promotion) // nolint: forcetypeassert
+	// If a Promotion is deleted while it is non-terminal, we want to enqueue
+	// the associated Stage so that it can reset its status.currentPromotion.
+	return !promo.Status.Phase.IsTerminal()
+}
+
+func (p PromoPhaseChanged[T]) Generic(event.TypedGenericEvent[T]) bool {
+	return false
+}
+
+func (p PromoPhaseChanged[T]) Update(e event.TypedUpdateEvent[T]) bool {
+	oldPromo := any(e.ObjectOld).(*kargoapi.Promotion) // nolint: forcetypeassert
+	if oldPromo == nil {
+		p.logger.Error(
+			nil, "Update event has no old object for update",
+			"event", e,
+		)
+		return false
+	}
+	newPromo := any(e.ObjectNew).(*kargoapi.Promotion) // nolint: forcetypeassert
+	if newPromo == nil {
+		p.logger.Error(
+			nil, "Update event has no new object for update",
+			"event", e,
+		)
+		return false
+	}
+	return newPromo.Status.Phase != oldPromo.Status.Phase
+}
+
 // RefreshRequested is a predicate that returns true if the refresh annotation
 // has been set on a resource, or the value of the annotation has changed
 // compared to the previous state.


Test patch:
diff --git a/api/v1alpha1/stage_types_test.go b/api/v1alpha1/stage_types_test.go
index b528d5d9..ea4612d8 100644
--- a/api/v1alpha1/stage_types_test.go
+++ b/api/v1alpha1/stage_types_test.go
@@ -37,6 +37,56 @@ func TestVerificationInfo_HasAnalysisRun(t *testing.T) {
 	}
 }
 
+func TestFreightReferenceStackPush(t *testing.T) {
+	testCases := []struct {
+		name          string
+		stack         FreightReferenceStack
+		newFreight    []FreightReference
+		expectedStack FreightReferenceStack
+	}{
+		{
+			name:          "initial stack is nil",
+			stack:         nil,
+			newFreight:    []FreightReference{{Name: "foo"}, {Name: "bar"}},
+			expectedStack: FreightReferenceStack{{Name: "foo"}, {Name: "bar"}},
+		},
+		{
+			name:          "initial stack is not nil",
+			stack:         FreightReferenceStack{{Name: "foo"}},
+			newFreight:    []FreightReference{{Name: "bar"}},
+			expectedStack: FreightReferenceStack{{Name: "bar"}, {Name: "foo"}},
+		},
+		{
+			name:       "initial stack has matching names",
+			stack:      FreightReferenceStack{{Name: "foo"}, {Name: "bar"}},
+			newFreight: []FreightReference{{Name: "bar"}, {Name: "baz"}, {Name: "zab"}},
+			expectedStack: FreightReferenceStack{
+				{Name: "bar"},
+				{Name: "baz"},
+				{Name: "zab"},
+				{Name: "foo"},
+				{Name: "bar"},
+			},
+		},
+		{
+			name: "initial stack is full",
+			stack: FreightReferenceStack{
+				{}, {}, {}, {}, {}, {}, {}, {}, {}, {},
+			},
+			newFreight: []FreightReference{{Name: "foo"}},
+			expectedStack: FreightReferenceStack{
+				{Name: "foo"}, {}, {}, {}, {}, {}, {}, {}, {}, {},
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			testCase.stack.Push(testCase.newFreight...)
+			require.Equal(t, testCase.expectedStack, testCase.stack)
+		})
+	}
+}
+
 func TestFreightReferenceStackUpdateOrPush(t *testing.T) {
 	testCases := []struct {
 		name          string
diff --git a/internal/controller/stages/stages_test.go b/internal/controller/stages/stages_test.go
index 7d52e60a..81f72982 100644
--- a/internal/controller/stages/stages_test.go
+++ b/internal/controller/stages/stages_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/oklog/ulid/v2"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
@@ -46,8 +47,9 @@ func TestNewReconciler(t *testing.T) {
 	// Assert that all overridable behaviors were initialized to a default:
 	// Loop guard:
 	require.NotNil(t, r.nowFn)
-	require.NotNil(t, r.hasNonTerminalPromotionsFn)
+	require.NotNil(t, r.getPromotionsForStageFn)
 	require.NotNil(t, r.listPromosFn)
+	require.NotNil(t, r.syncPromotionsFn)
 	// Freight verification:
 	require.NotNil(t, r.startVerificationFn)
 	require.NotNil(t, r.getVerificationInfoFn)
@@ -287,14 +289,6 @@ func TestSyncControlFlowStage(t *testing.T) {
 }
 
 func TestSyncNormalStage(t *testing.T) {
-	noNonTerminalPromotionsFn := func(
-		context.Context,
-		string,
-		string,
-	) (bool, error) {
-		return false, nil
-	}
-
 	testCases := []struct {
 		name       string
 		stage      *kargoapi.Stage
@@ -308,15 +302,15 @@ func TestSyncNormalStage(t *testing.T) {
 		)
 	}{
 		{
-			name:  "error checking for non-terminal promotions",
+			name:  "error syncing Promotions",
 			stage: &kargoapi.Stage{},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: func(
-					context.Context,
-					string,
-					string,
-				) (bool, error) {
-					return false, errors.New("something went wrong")
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, errors.New("something went wrong")
 				},
 			},
 			assertions: func(
@@ -336,34 +330,6 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 		},
 
-		{
-			name:  "non-terminal promotions found",
-			stage: &kargoapi.Stage{},
-			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: func(
-					context.Context,
-					string,
-					string,
-				) (bool, error) {
-					return true, nil
-				},
-			},
-			assertions: func(
-				t *testing.T,
-				recorder *fakeevent.EventRecorder,
-				initialStatus kargoapi.StageStatus,
-				newStatus kargoapi.StageStatus,
-				err error,
-			) {
-				require.NoError(t, err)
-				// Status should be returned unchanged
-				require.Equal(t, initialStatus, newStatus)
-
-				// No events should be recorded
-				require.Empty(t, recorder.Events)
-			},
-		},
-
 		{
 			name: "reverification requested",
 			stage: &kargoapi.Stage{
@@ -390,8 +356,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				startVerificationFn: func(
 					context.Context,
 					*kargoapi.Stage,
@@ -467,8 +439,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getFreightFn: func(
 					context.Context,
 					client.Client,
@@ -505,8 +483,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				startVerificationFn: func(
 					_ context.Context,
 					_ *kargoapi.Stage,
@@ -587,8 +571,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				startVerificationFn: func(
 					context.Context,
 					*kargoapi.Stage,
@@ -655,8 +645,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getFreightFn: func(
 					context.Context,
 					client.Client,
@@ -734,8 +730,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getVerificationInfoFn: func(_ context.Context, _ *kargoapi.Stage) (*kargoapi.VerificationInfo, error) {
 					return &kargoapi.VerificationInfo{
 						Phase:   kargoapi.VerificationPhaseError,
@@ -805,8 +807,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getAnalysisRunFn: func(
 					context.Context,
 					client.Client,
@@ -903,8 +911,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getAnalysisRunFn: func(
 					context.Context,
 					client.Client,
@@ -981,8 +995,14 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return false, errors.New("something went wrong")
 				},
@@ -1018,12 +1038,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1079,12 +1106,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1139,12 +1173,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1207,12 +1248,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				getFreightFn: func(
 					context.Context,
 					client.Client,
@@ -1264,14 +1312,21 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase: kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{
 						Name: "fake-freight-id",
 					},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1324,12 +1379,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1396,12 +1458,19 @@ func TestSyncNormalStage(t *testing.T) {
 					PromotionMechanisms: &kargoapi.PromotionMechanisms{},
 				},
 				Status: kargoapi.StageStatus{
+					Phase:          kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{},
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
-				appHealth:                  &mockAppHealthEvaluator{},
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
+				appHealth: &mockAppHealthEvaluator{},
 				verifyFreightInStageFn: func(context.Context, string, string, string) (bool, error) {
 					return true, nil
 				},
@@ -1491,8 +1560,7 @@ func TestSyncNormalStage(t *testing.T) {
 					Verification:        &kargoapi.Verification{},
 				},
 				Status: kargoapi.StageStatus{
-					Phase:            kargoapi.StagePhaseSteady,
-					CurrentPromotion: &kargoapi.PromotionInfo{},
+					Phase: kargoapi.StagePhaseSteady,
 					CurrentFreight: &kargoapi.FreightReference{
 						VerificationInfo: &kargoapi.VerificationInfo{
 							Phase: kargoapi.VerificationPhaseSuccessful,
@@ -1506,7 +1574,13 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
 				appHealth: &mockAppHealthEvaluator{
 					Health: &kargoapi.Health{
 						Status: kargoapi.HealthStateHealthy,
@@ -1588,9 +1662,7 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				require.Equal(t, int64(42), newStatus.ObservedGeneration) // Set
 				require.Equal(t, kargoapi.StagePhaseSteady, newStatus.Phase)
-				require.NotNil(t, newStatus.Health)        // Set
-				require.Nil(t, newStatus.CurrentPromotion) // Cleared
-				require.Equal(t, kargoapi.StagePhaseSteady, newStatus.Phase)
+				require.NotNil(t, newStatus.Health) // Set
 				require.Equal(
 					t,
 					&kargoapi.VerificationInfo{
@@ -1623,8 +1695,7 @@ func TestSyncNormalStage(t *testing.T) {
 					Verification:        &kargoapi.Verification{},
 				},
 				Status: kargoapi.StageStatus{
-					Phase:            kargoapi.StagePhaseVerifying,
-					CurrentPromotion: &kargoapi.PromotionInfo{},
+					Phase: kargoapi.StagePhaseVerifying,
 					CurrentFreight: &kargoapi.FreightReference{
 						VerificationInfo: &kargoapi.VerificationInfo{
 							Phase: kargoapi.VerificationPhasePending,
@@ -1637,7 +1708,13 @@ func TestSyncNormalStage(t *testing.T) {
 				},
 			},
 			reconciler: &reconciler{
-				hasNonTerminalPromotionsFn: noNonTerminalPromotionsFn,
+				syncPromotionsFn: func(
+					_ context.Context,
+					_ *kargoapi.Stage,
+					status kargoapi.StageStatus,
+				) (kargoapi.StageStatus, error) {
+					return status, nil
+				},
 				appHealth: &mockAppHealthEvaluator{
 					Health: &kargoapi.Health{
 						Status: kargoapi.HealthStateHealthy,
@@ -1727,9 +1804,7 @@ func TestSyncNormalStage(t *testing.T) {
 				require.NoError(t, err)
 				require.Equal(t, int64(42), newStatus.ObservedGeneration) // Set
 				require.Equal(t, kargoapi.StagePhaseSteady, newStatus.Phase)
-				require.NotNil(t, newStatus.Health)        // Set
-				require.Nil(t, newStatus.CurrentPromotion) // Cleared
-				require.Equal(t, kargoapi.StagePhaseSteady, newStatus.Phase)
+				require.NotNil(t, newStatus.Health) // Set
 				require.Equal(
 					t,
 					&kargoapi.VerificationInfo{
@@ -1777,6 +1852,242 @@ func TestSyncNormalStage(t *testing.T) {
 	}
 }
 
+func TestReconciler_syncPromotions(t *testing.T) {
+	now := fakeNow()
+	ulidOneMinuteAgo := ulid.MustNew(ulid.Timestamp(now.Add(-time.Minute)), nil)
+	ulidOneHourAgo := ulid.MustNew(ulid.Timestamp(now.Add(-time.Hour)), nil)
+	ulidOneDayAgo := ulid.MustNew(ulid.Timestamp(now.Add(-24*time.Hour)), nil)
+
+	testCases := []struct {
+		name          string
+		reconciler    *reconciler
+		initialStatus kargoapi.StageStatus
+		assertions    func(*testing.T, kargoapi.StageStatus, error)
+	}{
+		{
+			name: "error listing Promotions",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return nil, errors.New("something went wrong")
+				},
+			},
+			assertions: func(t *testing.T, _ kargoapi.StageStatus, err error) {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), "something went wrong")
+			},
+		},
+		{
+			name: "no Promotions found",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return nil, nil
+				},
+			},
+			assertions: func(t *testing.T, _ kargoapi.StageStatus, err error) {
+				require.NoError(t, err)
+			},
+		},
+		{
+			name: "latest Promotion is Running",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return []kargoapi.Promotion{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name:              "fake-promotion",
+								CreationTimestamp: metav1.NewTime(now),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseRunning,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight",
+								},
+							},
+						},
+					}, nil
+				},
+			},
+			assertions: func(t *testing.T, status kargoapi.StageStatus, err error) {
+				require.NoError(t, err)
+				require.Equal(t, kargoapi.StagePhasePromoting, status.Phase)
+				require.Equal(t, &kargoapi.PromotionInfo{
+					Name: "fake-promotion",
+					Freight: kargoapi.FreightReference{
+						Name: "fake-freight",
+					},
+				}, status.CurrentPromotion)
+			},
+		},
+		{
+			name: "latest Promotion is not Running anymore",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return []kargoapi.Promotion{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name:              "fake-promotion",
+								CreationTimestamp: metav1.NewTime(now),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseSucceeded,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight",
+								},
+							},
+						},
+					}, nil
+				},
+			},
+			initialStatus: kargoapi.StageStatus{
+				Phase: kargoapi.StagePhasePromoting,
+				CurrentPromotion: &kargoapi.PromotionInfo{
+					Name: "fake-promotion",
+				},
+			},
+			assertions: func(t *testing.T, status kargoapi.StageStatus, err error) {
+				require.NoError(t, err)
+				require.Equal(t, kargoapi.StagePhaseSteady, status.Phase)
+				require.Nil(t, status.CurrentPromotion)
+			},
+		},
+		{
+			name: "new Terminated Promotions",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return []kargoapi.Promotion{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fake-promotion." + ulidOneDayAgo.String(),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseSucceeded,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight-1",
+								},
+							},
+						},
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fake-promotion." + ulidOneMinuteAgo.String(),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseErrored,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight-3",
+								},
+							},
+						},
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fake-promotion." + ulidOneHourAgo.String(),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseFailed,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight-2",
+								},
+							},
+						},
+					}, nil
+				},
+			},
+			assertions: func(t *testing.T, status kargoapi.StageStatus, err error) {
+				require.NoError(t, err)
+
+				require.Equal(t, kargoapi.StagePhaseSteady, status.Phase)
+				require.Nil(t, status.CurrentPromotion)
+
+				// Last Promotion should be the latest Terminated Promotion
+				require.Equal(t, &kargoapi.PromotionInfo{
+					Name: "fake-promotion." + ulidOneMinuteAgo.String(),
+					Status: &kargoapi.PromotionStatus{
+						Phase: kargoapi.PromotionPhaseErrored,
+						Freight: &kargoapi.FreightReference{
+							Name: "fake-freight-3",
+						},
+					},
+					Freight: kargoapi.FreightReference{
+						Name: "fake-freight-3",
+					},
+				}, status.LastPromotion)
+
+				// Current Freight should be the Freight of the last Succeeded Promotion
+				require.Equal(t, &kargoapi.FreightReference{
+					Name: "fake-freight-1",
+				}, status.CurrentFreight)
+				require.Equal(t, kargoapi.FreightReferenceStack{
+					{Name: "fake-freight-1"},
+				}, status.History)
+			},
+		},
+		{
+			name: "no new Terminated Promotions",
+			reconciler: &reconciler{
+				getPromotionsForStageFn: func(context.Context, string, string) ([]kargoapi.Promotion, error) {
+					return []kargoapi.Promotion{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fake-promotion." + ulidOneDayAgo.String(),
+							},
+							Status: kargoapi.PromotionStatus{
+								Phase: kargoapi.PromotionPhaseSucceeded,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight-1",
+								},
+							},
+						},
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "fake-promotion." + ulidOneHourAgo.String(),
+							},
+							Status: kargoapi.PromotionStatus{
+								// Phase update should be ignored
+								Phase: kargoapi.PromotionPhaseSucceeded,
+								Freight: &kargoapi.FreightReference{
+									Name: "fake-freight-2",
+								},
+							},
+						},
+					}, nil
+				},
+			},
+			initialStatus: kargoapi.StageStatus{
+				// Should not be updated.
+				Phase: kargoapi.StagePhaseVerifying,
+				LastPromotion: &kargoapi.PromotionInfo{
+					Name: "fake-promotion." + ulidOneHourAgo.String(),
+					Status: &kargoapi.PromotionStatus{
+						Phase: kargoapi.PromotionPhaseFailed,
+					},
+				},
+			},
+			assertions: func(t *testing.T, status kargoapi.StageStatus, err error) {
+				require.NoError(t, err)
+
+				require.Equal(t, kargoapi.StagePhaseVerifying, status.Phase)
+				require.Nil(t, status.CurrentPromotion)
+				require.Equal(t, &kargoapi.PromotionInfo{
+					Name: "fake-promotion." + ulidOneHourAgo.String(),
+					Status: &kargoapi.PromotionStatus{
+						Phase: kargoapi.PromotionPhaseFailed,
+					},
+				}, status.LastPromotion)
+				require.Len(t, status.History, 0)
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			status, err := testCase.reconciler.syncPromotions(
+				context.Background(),
+				&kargoapi.Stage{},
+				testCase.initialStatus,
+			)
+			testCase.assertions(t, status, err)
+		})
+	}
+}
+
 func TestSyncStageDelete(t *testing.T) {
 	testCases := []struct {
 		name       string
@@ -2056,78 +2367,6 @@ func TestClearApprovals(t *testing.T) {
 	}
 }
 
-func TestHasNonTerminalPromotions(t *testing.T) {
-	testCases := []struct {
-		name       string
-		reconciler *reconciler
-		assertions func(*testing.T, bool, error)
-	}{
-		{
-			name: "error listing Promotions",
-			reconciler: &reconciler{
-				listPromosFn: func(
-					context.Context,
-					client.ObjectList,
-					...client.ListOption,
-				) error {
-					return errors.New("something went wrong")
-				},
-			},
-			assertions: func(t *testing.T, _ bool, err error) {
-				require.ErrorContains(t, err, "something went wrong")
-			},
-		},
-		{
-			name: "has non-terminal Promotions",
-			reconciler: &reconciler{
-				listPromosFn: func(
-					_ context.Context,
-					objList client.ObjectList,
-					_ ...client.ListOption,
-				) error {
-					promos, ok := objList.(*kargoapi.PromotionList)
-					require.True(t, ok)
-					promos.Items = []kargoapi.Promotion{{}}
-					return nil
-				},
-			},
-			assertions: func(t *testing.T, result bool, err error) {
-				require.NoError(t, err)
-				require.True(t, result)
-			},
-		},
-		{
-			name: "does not have non-terminal Promotions",
-			reconciler: &reconciler{
-				listPromosFn: func(
-					_ context.Context,
-					objList client.ObjectList,
-					_ ...client.ListOption,
-				) error {
-					promos, ok := objList.(*kargoapi.PromotionList)
-					require.True(t, ok)
-					promos.Items = []kargoapi.Promotion{}
-					return nil
-				},
-			},
-			assertions: func(t *testing.T, result bool, err error) {
-				require.NoError(t, err)
-				require.False(t, result)
-			},
-		},
-	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			result, err := testCase.reconciler.hasNonTerminalPromotions(
-				context.Background(),
-				"fake-namespace",
-				"fake-stage",
-			)
-			testCase.assertions(t, result, err)
-		})
-	}
-}
-
 func TestVerifyFreightInStage(t *testing.T) {
 	testCases := []struct {
 		name       string
@@ -2356,6 +2595,58 @@ func TestIsAutoPromotionPermitted(t *testing.T) {
 	}
 }
 
+func TestGetPromotionsForStage(t *testing.T) {
+	testCases := []struct {
+		name       string
+		reconciler *reconciler
+		assertions func(*testing.T, []kargoapi.Promotion, error)
+	}{
+		{
+			name: "error listing Promotions",
+			reconciler: &reconciler{
+				listPromosFn: func(
+					context.Context,
+					client.ObjectList,
+					...client.ListOption,
+				) error {
+					return errors.New("something went wrong")
+				},
+			},
+			assertions: func(t *testing.T, _ []kargoapi.Promotion, err error) {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), "something went wrong")
+				require.Contains(t, err.Error(), "error listing Promotions")
+			},
+		},
+		{
+			name: "success",
+			reconciler: &reconciler{
+				listPromosFn: func(
+					context.Context,
+					client.ObjectList,
+					...client.ListOption,
+				) error {
+					return nil
+				},
+			},
+			assertions: func(t *testing.T, promos []kargoapi.Promotion, err error) {
+				require.NoError(t, err)
+				require.Empty(t, promos)
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			promos, err := testCase.reconciler.getPromotionsForStage(
+				context.Background(),
+				"fake-namespace",
+				"fake-stage",
+			)
+			testCase.assertions(t, promos, err)
+		})
+	}
+}
+
 func TestGetLatestAvailableFreight(t *testing.T) {
 	now := time.Now().UTC()
 	testCases := []struct {
@@ -2907,6 +3198,226 @@ func TestGetLatestVerifiedFreight(t *testing.T) {
 	}
 }
 
+func Test_comparePromotionByPhaseAndCreationTime(t *testing.T) {
+	now := fakeNow()
+	ulidEarlier := ulid.MustNew(ulid.Timestamp(now.Add(-time.Hour)), nil)
+	ulidLater := ulid.MustNew(ulid.Timestamp(now.Add(time.Hour)), nil)
+
+	tests := []struct {
+		name     string
+		a        kargoapi.Promotion
+		b        kargoapi.Promotion
+		expected int
+	}{
+		{
+			name: "Running before Terminated",
+			a: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseRunning,
+				},
+			},
+			b: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseSucceeded,
+				},
+			},
+			expected: -1,
+		},
+		{
+			name: "Pending before Terminated",
+			a: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			b: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseSucceeded,
+				},
+			},
+			expected: -1,
+		},
+		{
+			name: "Pending after Running",
+			a: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			b: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseRunning,
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "Terminated after Running",
+			a: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseFailed,
+				},
+			},
+			b: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseRunning,
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "Earlier ULID first if both Running",
+			a: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "promotion." + ulidEarlier.String(),
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseRunning,
+				},
+			},
+			b: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "promotion." + ulidLater.String(),
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseRunning,
+				},
+			},
+			expected: -1,
+		},
+		{
+			name: "Later ULID first if both Terminated",
+			a: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "promotion." + ulidLater.String(),
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseErrored,
+				},
+			},
+			b: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "promotion." + ulidEarlier.String(),
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseSucceeded,
+				},
+			},
+			expected: -1,
+		},
+		{
+			name: "Equal promotions",
+			a: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:              "promotion-a",
+					CreationTimestamp: metav1.Time{Time: now},
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			b: kargoapi.Promotion{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:              "promotion-a",
+					CreationTimestamp: metav1.Time{Time: now},
+				},
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			expected: 0,
+		},
+		{
+			name: "Nil creation timestamps",
+			a: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			b: kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			expected: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := comparePromotionByPhaseAndCreationTime(tt.a, tt.b)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+func Test_comparePromotionPhase(t *testing.T) {
+	tests := []struct {
+		name     string
+		a        kargoapi.PromotionPhase
+		b        kargoapi.PromotionPhase
+		expected int
+	}{
+		{
+			name:     "Running before Terminated",
+			a:        kargoapi.PromotionPhaseRunning,
+			b:        kargoapi.PromotionPhaseSucceeded,
+			expected: -1,
+		},
+		{
+			name:     "Terminated after Running",
+			a:        kargoapi.PromotionPhaseFailed,
+			b:        kargoapi.PromotionPhaseRunning,
+			expected: 1,
+		},
+		{
+			name:     "Running before other phase",
+			a:        kargoapi.PromotionPhaseRunning,
+			b:        kargoapi.PromotionPhasePending,
+			expected: -1,
+		},
+		{
+			name:     "Other phase after Running",
+			a:        "",
+			b:        kargoapi.PromotionPhaseRunning,
+			expected: 1,
+		},
+		{
+			name:     "Pending before Terminated",
+			a:        kargoapi.PromotionPhasePending,
+			b:        kargoapi.PromotionPhaseErrored,
+			expected: -1,
+		},
+		{
+			name:     "Pending after Running",
+			a:        kargoapi.PromotionPhasePending,
+			b:        kargoapi.PromotionPhaseRunning,
+			expected: 1,
+		},
+		{
+			name:     "Equal Running phases",
+			a:        kargoapi.PromotionPhaseRunning,
+			b:        kargoapi.PromotionPhaseRunning,
+			expected: 0,
+		},
+		{
+			name: "Equal Terminated phases",
+			a:    kargoapi.PromotionPhaseSucceeded,
+			b:    kargoapi.PromotionPhaseFailed,
+		},
+		{
+			name:     "Equal other phases",
+			a:        kargoapi.PromotionPhasePending,
+			b:        kargoapi.PromotionPhasePending,
+			expected: 0,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require.Equal(t, tt.expected, comparePromotionPhase(tt.a, tt.b))
+		})
+	}
+}
+
 func fakeNow() time.Time {
 	return fakeTime
 }
diff --git a/internal/kargo/kargo_test.go b/internal/kargo/kargo_test.go
index cca70658..6de875ce 100644
--- a/internal/kargo/kargo_test.go
+++ b/internal/kargo/kargo_test.go
@@ -11,6 +11,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/event"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/logging"
 )
 
 func TestNewPromotion(t *testing.T) {
@@ -75,6 +76,72 @@ func TestNewPromotion(t *testing.T) {
 	}
 }
 
+func TestPromoPhaseChanged_Update(t *testing.T) {
+	tests := []struct {
+		name      string
+		oldObject *kargoapi.Promotion
+		newObject *kargoapi.Promotion
+		want      bool
+	}{
+		{
+			name:      "no old or new object",
+			oldObject: nil,
+			newObject: nil,
+			want:      false,
+		},
+		{
+			name:      "no old object",
+			oldObject: nil,
+			newObject: &kargoapi.Promotion{},
+			want:      false,
+		},
+		{
+			name:      "no new object",
+			oldObject: &kargoapi.Promotion{},
+			newObject: nil,
+			want:      false,
+		},
+		{
+			name: "no phase change",
+			oldObject: &kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			newObject: &kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			want: false,
+		},
+		{
+			name: "phase changed",
+			oldObject: &kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhasePending,
+				},
+			},
+			newObject: &kargoapi.Promotion{
+				Status: kargoapi.PromotionStatus{
+					Phase: kargoapi.PromotionPhaseErrored,
+				},
+			},
+			want: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			p := NewPromoPhaseChangedPredicate(logging.NewLogger(logging.InfoLevel))
+			require.Equal(t, tt.want, p.Update(event.TypedUpdateEvent[*kargoapi.Promotion]{
+				ObjectOld: tt.oldObject,
+				ObjectNew: tt.newObject,
+			}))
+		})
+	}
+}
+
 func TestRefreshRequested_Update(t *testing.T) {
 	tests := []struct {
 		name      string