diff --git a/api/v1alpha1/promotion_types.go b/api/v1alpha1/promotion_types.go
index 1b36d50914..ac72f9fcb0 100644
--- a/api/v1alpha1/promotion_types.go
+++ b/api/v1alpha1/promotion_types.go
@@ -1,6 +1,7 @@
 package v1alpha1
 
 import (
+	"fmt"
 	"time"
 
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -224,6 +225,21 @@ type PromotionStep struct {
 	Config *apiextensionsv1.JSON `json:"config,omitempty" protobuf:"bytes,3,opt,name=config"`
 }
 
+// GetAlias returns the As field, or a default value in the form of "step-<i>"
+// or "task-<i>" if the As field is empty. The index i is provided as an
+// argument to this method and should be the index of the PromotionStep in the
+// list it belongs to.
+func (s *PromotionStep) GetAlias(i int) string {
+	switch {
+	case s.As != "":
+		return s.As
+	case s.Task != nil:
+		return fmt.Sprintf("task-%d", i)
+	default:
+		return fmt.Sprintf("step-%d", i)
+	}
+}
+
 // PromotionStatus describes the current state of the transition represented by
 // a Promotion.
 type PromotionStatus struct {
diff --git a/internal/api/promote_downstream_v1alpha1.go b/internal/api/promote_downstream_v1alpha1.go
index 888194f95c..ccf616acb3 100644
--- a/internal/api/promote_downstream_v1alpha1.go
+++ b/internal/api/promote_downstream_v1alpha1.go
@@ -137,19 +137,23 @@ func (s *server) PromoteDownstream(
 	promoteErrs := make([]error, 0, len(downstreams))
 	createdPromos := make([]*kargoapi.Promotion, 0, len(downstreams))
 	for _, downstream := range downstreams {
-		newPromo := kargo.NewPromotion(ctx, downstream, freight.Name)
 		if downstream.Spec.PromotionTemplate != nil &&
 			len(downstream.Spec.PromotionTemplate.Spec.Steps) == 0 {
 			// Avoid creating a Promotion if the downstream Stage has no promotion
 			// steps and is therefore a "control flow" Stage.
 			continue
 		}
-		if err := s.createPromotionFn(ctx, &newPromo); err != nil {
+		newPromo, err := kargo.NewPromotionBuilder(s.client).Build(ctx, downstream, freight.Name)
+		if err != nil {
 			promoteErrs = append(promoteErrs, err)
 			continue
 		}
-		s.recordPromotionCreatedEvent(ctx, &newPromo, freight)
-		createdPromos = append(createdPromos, &newPromo)
+		if err = s.createPromotionFn(ctx, newPromo); err != nil {
+			promoteErrs = append(promoteErrs, err)
+			continue
+		}
+		s.recordPromotionCreatedEvent(ctx, newPromo, freight)
+		createdPromos = append(createdPromos, newPromo)
 	}
 
 	res := connect.NewResponse(&svcv1alpha1.PromoteDownstreamResponse{
diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go
index 310d9cd059..d6b7330ab7 100644
--- a/internal/api/promote_to_stage_v1alpha1.go
+++ b/internal/api/promote_to_stage_v1alpha1.go
@@ -98,7 +98,7 @@ func (s *server) PromoteToStage(
 		)
 	}
 
-	if err := s.authorizeFn(
+	if err = s.authorizeFn(
 		ctx,
 		"promote",
 		schema.GroupVersionResource{
@@ -115,13 +115,16 @@ func (s *server) PromoteToStage(
 		return nil, err
 	}
 
-	promotion := kargo.NewPromotion(ctx, *stage, freight.Name)
-	if err := s.createPromotionFn(ctx, &promotion); err != nil {
+	promotion, err := kargo.NewPromotionBuilder(s.client).Build(ctx, *stage, freight.Name)
+	if err != nil {
+		return nil, fmt.Errorf("build promotion: %w", err)
+	}
+	if err := s.createPromotionFn(ctx, promotion); err != nil {
 		return nil, fmt.Errorf("create promotion: %w", err)
 	}
-	s.recordPromotionCreatedEvent(ctx, &promotion, freight)
+	s.recordPromotionCreatedEvent(ctx, promotion, freight)
 	return connect.NewResponse(&svcv1alpha1.PromoteToStageResponse{
-		Promotion: &promotion,
+		Promotion: promotion,
 	}), nil
 }
 
diff --git a/internal/controller/stages/regular_stages.go b/internal/controller/stages/regular_stages.go
index 1213cae4dd..acd81b0507 100644
--- a/internal/controller/stages/regular_stages.go
+++ b/internal/controller/stages/regular_stages.go
@@ -1573,19 +1573,25 @@ func (r *RegularStageReconciler) autoPromoteFreight(
 		}
 
 		// Auto promote the latest available Freight and record an event.
-		promotion := kargo.NewPromotion(ctx, *stage, latestFreight.Name)
-		if err := r.client.Create(ctx, &promotion); err != nil {
+		promotion, err := kargo.NewPromotionBuilder(r.client).Build(ctx, *stage, latestFreight.Name)
+		if err != nil {
+			return newStatus, fmt.Errorf(
+				"error building Promotion for Freight %q in namespace %q: %w",
+				latestFreight.Name, stage.Namespace, err,
+			)
+		}
+		if err = r.client.Create(ctx, promotion); err != nil {
 			return newStatus, fmt.Errorf(
 				"error creating Promotion for Freight %q in namespace %q: %w",
 				latestFreight.Name, stage.Namespace, err,
 			)
 		}
 		r.eventRecorder.AnnotatedEventf(
-			&promotion,
+			promotion,
 			kargoEvent.NewPromotionAnnotations(
 				ctx,
 				kargoapi.FormatEventControllerActor(r.cfg.Name()),
-				&promotion,
+				promotion,
 				&latestFreight,
 			),
 			corev1.EventTypeNormal,
diff --git a/internal/kargo/kargo.go b/internal/kargo/kargo.go
index d3c1ad7d20..148bd14c6a 100644
--- a/internal/kargo/kargo.go
+++ b/internal/kargo/kargo.go
@@ -1,71 +1,13 @@
 package kargo
 
 import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/oklog/ulid/v2"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	"github.com/akuity/kargo/internal/api/user"
 	"github.com/akuity/kargo/internal/logging"
 )
 
-const (
-	// maximum length of the stage name used in the promotion name prefix before it exceeds
-	// kubernetes resource name limit of 253
-	// 253 - 1 (.) - 26 (ulid) - 1 (.) - 7 (sha) = 218
-	maxStageNamePrefixLength = 218
-)
-
-// NewPromotion returns a new Promotion from a given stage and freight with our
-// naming convention.
-func NewPromotion(
-	ctx context.Context,
-	stage kargoapi.Stage,
-	freight string,
-) kargoapi.Promotion {
-	shortHash := freight
-	if len(shortHash) > 7 {
-		shortHash = freight[0:7]
-	}
-	shortStageName := stage.Name
-	if len(stage.Name) > maxStageNamePrefixLength {
-		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,
-			Annotations: annotations,
-		},
-		Spec: kargoapi.PromotionSpec{
-			Stage:   stage.Name,
-			Freight: freight,
-		},
-	}
-	if stage.Spec.PromotionTemplate != nil {
-		promotion.Spec.Vars = stage.Spec.PromotionTemplate.Spec.Vars
-		promotion.Spec.Steps = stage.Spec.PromotionTemplate.Spec.Steps
-	}
-	return promotion
-}
-
 func NewPromoWentTerminalPredicate(logger *logging.Logger) PromoWentTerminal[*kargoapi.Promotion] {
 	return PromoWentTerminal[*kargoapi.Promotion]{
 		logger: logger,
diff --git a/internal/kargo/kargo_test.go b/internal/kargo/kargo_test.go
index c71d9f9291..da5abc9f79 100644
--- a/internal/kargo/kargo_test.go
+++ b/internal/kargo/kargo_test.go
@@ -37,6 +37,17 @@ func TestNewPromotion(t *testing.T) {
 					Name:      "test",
 					Namespace: "kargo-demo",
 				},
+				Spec: kargoapi.StageSpec{
+					PromotionTemplate: &kargoapi.PromotionTemplate{
+						Spec: kargoapi.PromotionTemplateSpec{
+							Steps: []kargoapi.PromotionStep{
+								{
+									Uses: "fake-step",
+								},
+							},
+						},
+					},
+				},
 			},
 			freight: testFreight,
 			assertions: func(t *testing.T, _ kargoapi.Stage, promo kargoapi.Promotion) {
@@ -53,6 +64,17 @@ func TestNewPromotion(t *testing.T) {
 					Name:      veryLongResourceName,
 					Namespace: "kargo-demo",
 				},
+				Spec: kargoapi.StageSpec{
+					PromotionTemplate: &kargoapi.PromotionTemplate{
+						Spec: kargoapi.PromotionTemplateSpec{
+							Steps: []kargoapi.PromotionStep{
+								{
+									Uses: "fake-step",
+								},
+							},
+						},
+					},
+				},
 			},
 			freight: testFreight,
 			assertions: func(t *testing.T, _ kargoapi.Stage, promo kargoapi.Promotion) {
@@ -65,12 +87,13 @@ func TestNewPromotion(t *testing.T) {
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			promo := NewPromotion(context.TODO(), tc.stage, tc.freight)
+			promo, err := NewPromotionBuilder(nil).Build(context.TODO(), tc.stage, tc.freight)
+			require.NoError(t, err)
 			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)
 			require.LessOrEqual(t, len(promo.Name), 253)
-			tc.assertions(t, tc.stage, promo)
+			tc.assertions(t, tc.stage, *promo)
 		})
 	}
 }
diff --git a/internal/kargo/promotion_builder.go b/internal/kargo/promotion_builder.go
new file mode 100644
index 0000000000..1ceb2de350
--- /dev/null
+++ b/internal/kargo/promotion_builder.go
@@ -0,0 +1,237 @@
+package kargo
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/oklog/ulid/v2"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+
+	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api/user"
+)
+
+const (
+	// nameSeparator is the separator used in the Promotion name.
+	nameSeparator = "."
+
+	// ulidLength is the length of the ULID string.
+	ulidLength = ulid.EncodedSize
+
+	// shortHashLength is the length of the short hash.
+	shortHashLength = 7
+
+	// maxStageNamePrefixLength is the maximum length of the Stage name
+	// used in the Promotion name prefix before it exceeds the Kubernetes
+	// resource name limit of 253.
+	maxStageNamePrefixLength = 253 - len(nameSeparator) - ulidLength - len(nameSeparator) - shortHashLength
+)
+
+type PromotionBuilder struct {
+	client client.Client
+}
+
+// NewPromotionBuilder creates a new PromotionBuilder with the given client.
+func NewPromotionBuilder(c client.Client) *PromotionBuilder {
+	return &PromotionBuilder{
+		client: c,
+	}
+}
+
+// Build creates a new Promotion for the Freight based on the PromotionTemplate
+// of the given Stage.
+func (b *PromotionBuilder) Build(
+	ctx context.Context,
+	stage kargoapi.Stage,
+	freight string,
+) (*kargoapi.Promotion, error) {
+	if stage.Name == "" {
+		return nil, fmt.Errorf("stage is required")
+	}
+
+	if stage.Spec.PromotionTemplate == nil {
+		return nil, fmt.Errorf("stage %q has no promotion template", stage.Name)
+	}
+
+	if freight == "" {
+		return nil, fmt.Errorf("freight is required")
+	}
+
+	// Build metadata
+	annotations := make(map[string]string)
+	if u, ok := user.InfoFromContext(ctx); ok {
+		annotations[kargoapi.AnnotationKeyCreateActor] = kargoapi.FormatEventUserActor(u)
+	}
+
+	// Build steps
+	steps, err := b.buildSteps(ctx, stage)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build promotion steps: %w", err)
+	}
+
+	promotion := kargoapi.Promotion{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:        generatePromotionName(stage.Name, freight),
+			Namespace:   stage.Namespace,
+			Annotations: annotations,
+		},
+		Spec: kargoapi.PromotionSpec{
+			Stage:   stage.Name,
+			Freight: freight,
+			Vars:    stage.Spec.PromotionTemplate.Spec.Vars,
+			Steps:   steps,
+		},
+	}
+	return &promotion, nil
+}
+
+// buildSteps processes the Promotion steps from the PromotionTemplate of the
+// given Stage. If a PromotionStep references a PromotionTask, the task is
+// retrieved and its steps are inflated with the given task inputs.
+func (b *PromotionBuilder) buildSteps(ctx context.Context, stage kargoapi.Stage) ([]kargoapi.PromotionStep, error) {
+	steps := make([]kargoapi.PromotionStep, 0, len(stage.Spec.PromotionTemplate.Spec.Steps))
+	for i, step := range stage.Spec.PromotionTemplate.Spec.Steps {
+		switch {
+		case step.Task != nil:
+			alias := step.GetAlias(i)
+			taskSteps, err := b.inflateTaskSteps(ctx, stage.Namespace, alias, step)
+			if err != nil {
+				return nil, fmt.Errorf("inflate tasks steps for task %q (%q): %w", step.Task.Name, alias, err)
+			}
+			steps = append(steps, taskSteps...)
+		default:
+			steps = append(steps, step)
+		}
+	}
+	return steps, nil
+}
+
+// inflateTaskSteps inflates the PromotionSteps for the given PromotionStep
+// that references a (Cluster)PromotionTask. The task is retrieved and its
+// steps are inflated with the given task inputs.
+func (b *PromotionBuilder) inflateTaskSteps(
+	ctx context.Context,
+	project, taskAlias string,
+	taskStep kargoapi.PromotionStep,
+) ([]kargoapi.PromotionStep, error) {
+	task, err := b.getTaskSpec(ctx, project, taskStep.Task)
+	if err != nil {
+		return nil, err
+	}
+
+	inputs, err := validateAndMapTaskInputs(task.Inputs, taskStep.Config)
+	if err != nil {
+		return nil, err
+	}
+
+	var steps []kargoapi.PromotionStep
+	for i := range task.Steps {
+		// Copy the step as-is.
+		step := &task.Steps[i]
+
+		// Ensures we have a unique alias for each step within the context of
+		// the Promotion.
+		step.As = fmt.Sprintf("%s::%s", taskAlias, step.GetAlias(i))
+
+		// With the inputs validated and mapped, they are now available to
+		// the Config of the step during the Promotion execution.
+		step.Inputs = inputs
+
+		// Append the inflated step to the list of steps.
+		steps = append(steps, *step)
+	}
+	return steps, nil
+}
+
+// getTaskSpec retrieves the PromotionTaskSpec for the given PromotionTaskReference.
+func (b *PromotionBuilder) getTaskSpec(
+	ctx context.Context,
+	project string,
+	ref *kargoapi.PromotionTaskReference,
+) (*kargoapi.PromotionTaskSpec, error) {
+	var spec kargoapi.PromotionTaskSpec
+
+	if ref == nil {
+		return nil, errors.New("missing task reference")
+	}
+
+	switch ref.Kind {
+	case "PromotionTask", "":
+		task := &kargoapi.PromotionTask{}
+		if err := b.client.Get(ctx, client.ObjectKey{Namespace: project, Name: ref.Name}, task); err != nil {
+			return nil, err
+		}
+		spec = task.Spec
+	case "ClusterPromotionTask":
+		task := &kargoapi.ClusterPromotionTask{}
+		if err := b.client.Get(ctx, client.ObjectKey{Name: ref.Name}, task); err != nil {
+			return nil, err
+		}
+		spec = task.Spec
+	default:
+		return nil, fmt.Errorf("unknown task reference kind %q", ref.Kind)
+	}
+
+	return &spec, nil
+}
+
+// generatePromotionName generates a name for the Promotion by combining the
+// Stage name, a ULID, and a short hash of the Freight.
+//
+// The name has the format of:
+//
+//	<stage-name>.<ulid>.<short-hash>
+func generatePromotionName(stageName, freight string) string {
+	shortHash := freight
+	if len(shortHash) > shortHashLength {
+		shortHash = shortHash[0:shortHashLength]
+	}
+
+	shortStageName := stageName
+	if len(stageName) > maxStageNamePrefixLength {
+		shortStageName = shortStageName[0:maxStageNamePrefixLength]
+	}
+
+	parts := []string{shortStageName, ulid.Make().String(), shortHash}
+	return strings.ToLower(strings.Join(parts, nameSeparator))
+}
+
+func validateAndMapTaskInputs(
+	taskInputs []kargoapi.PromotionTaskInput,
+	stepConfig *apiextensionsv1.JSON,
+) (map[string]string, error) {
+	if len(taskInputs) == 0 {
+		return nil, nil
+	}
+
+	if stepConfig == nil {
+		return nil, errors.New("missing step config")
+	}
+
+	config := make(map[string]any, len(taskInputs))
+	if err := yaml.Unmarshal(stepConfig.Raw, &config); err != nil {
+		return nil, fmt.Errorf("unmarshal step config: %w", err)
+	}
+
+	inputs := make(map[string]string, len(taskInputs))
+	for _, input := range taskInputs {
+		iv := input.Default
+		if cv, exists := config[input.Name]; exists {
+			strVal, ok := cv.(string)
+			if !ok {
+				return nil, fmt.Errorf("input %q must be a string", input.Name)
+			}
+			iv = strVal
+		}
+		if iv == "" {
+			return nil, fmt.Errorf("missing required input %q", input.Name)
+		}
+		inputs[input.Name] = iv
+	}
+	return inputs, nil
+}