Repository: akuity/kargo

Issue:
Can't apply existing Kargo Project configs to Kubernetes after upgrading to 1.5.0
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate bug report.
* [x] I've included steps to reproduce the bug.
* [x] I've pasted the output of `kargo version`.
* [ ] I've pasted logs, if applicable.

# Description

After upgrading to 1.5.0, our existing projects that and does not have a `ProjectConfig` fails to apply (sync through ArgoCD). We have our projects defined in yaml files in a git repository, synced through ArgoCD. We do not use the Kargo UI to do any configurations other than promotions.

```
error when patching "/dev/shm/4234176703": admission webhook "project.kargo.akuity.io" denied the request: Project.kargo.akuity.io "examplename" is invalid: spec: Forbidden: deprecated field: create a ProjectConfig named "examplename" with the config instead
```

# Steps to reproduce
Try to apply a Kargo Project yaml directly to kubernetes that uses the old `spec` directly on the Project and not in a ProjectConfig

# Version

```
{
  "Version": "v1.5.0",
  "BuildDate": "2025-05-15T22:29:42Z",
  "GitCommit": "61a1c24b7971bb19fcb430c737ca48e28912200c",
  "GitTreeDirty": false,
  "GoVersion": "go1.24.3",
  "Compiler": "gc",
  "Platform": "linux/amd64"
}
```



Discussion:
@jessesuen: In v1.5, Project configuration was moved from `spec` to a new separate namespace resource, `ProjectConfig` (See https://github.com/akuity/kargo/issues/3669). So you would gitops a resource, like:


```
---
apiVersion: kargo.akuity.io/v1alpha1
kind: Project
metadata:
   name: example
---
apiVersion: kargo.akuity.io/v1alpha1
kind: ProjectConfig
metadata:
  name: example
  namespace: example
spec:
  promotionPolicies:
  - stageSelector:
      name: test
    autoPromotionEnabled: true
  - stageSelector:
      name: uat
    autoPromotionEnabled: true
```@ThreePinkApples: Yes, but I would have expected the old config to work until full removal in 1.7.0 so we can upgrade to 1.5 first, and then do the changes a little later when it is unlikely that we'd revert back to 1.4.4. But not that big of a deal, a search-replace and do it all in one commit makes reverting easy enough@krancour: We may need to have a closer look at the decision to stop accepting Project spec. We have Project spec to ProjectConfig spec migration logic built in. I think that migration is complicated quite a bit by continuing to accept Project spec, but we will take a look at what, if anything, can be done to improve this.

cc @hiddeco for any input he may have on this.@hiddeco: The way I designed it to work, and briefly tested, is that it should continue to accept the `.spec` in the `Project` for as long as it equals the current `.spec` (i.e., you can't make changes, but it should continue to apply the unchanged `Project`). Based on what is being described, I suspect this does not work as intended.

Continuing to accept a `Project` with `.spec` changes is indeed troublesome, as it complicates the migration logic and could result in issues similar to a "split brain."@krancour: > The way I designed it to work, and briefly tested, is that it should continue to accept the .spec in the Project for as long as it equals the current .spec (i.e., you can't make changes, but it should continue to apply the unchanged Project). Based on what is being described, I suspect this does not work as intended.

The migration process clears out the old spec, so re-applying the Project as it appeared before migration looks to the webhook like it _is_ a change.

Solution patch:
diff --git a/api/v1alpha1/annotations.go b/api/v1alpha1/annotations.go
index aa151b5e..2f15fd7b 100644
--- a/api/v1alpha1/annotations.go
+++ b/api/v1alpha1/annotations.go
@@ -42,4 +42,14 @@ const (
 	// AnnotationKeyArgoCDContext is an annotation key that is set on a Stage
 	// to reference the last ArgoCD Applications that were part of a Promotion.
 	AnnotationKeyArgoCDContext = "kargo.akuity.io/argocd-context"
+
+	// AnnotationKeyMigrated is an annotation set on a resource that has
+	// successfully undergone a migration to a new resource type or other
+	// configuration change. This annotation is used to indicate that the
+	// resource has been successfully migrated and that the controller should
+	// not attempt to perform the migration again.
+	//
+	// The value of the annotation is a JSON object that maps migration types to
+	// booleans indicating whether the migration has been performed.
+	AnnotationKeyMigrated = "kargo.akuity.io/migrated"
 )
diff --git a/internal/api/annotations.go b/internal/api/annotations.go
index 8e31218d..a675b097 100644
--- a/internal/api/annotations.go
+++ b/internal/api/annotations.go
@@ -3,9 +3,20 @@ package api
 import (
 	"encoding/json"
 
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 )
 
+const (
+	// MigratedProjectSpecToProjectConfig is the migration type for migrating
+	// ProjectSpec resources to ProjectConfig resources. This migration is
+	// performed by the controller when it detects that a Project resource
+	// has a ProjectSpec resource in its spec, which is the case for Projects
+	// created before the introduction of ProjectConfig resources.
+	MigratedProjectSpecToProjectConfig = "ProjectSpecToProjectConfig"
+)
+
 // RefreshAnnotationValue returns the value of the AnnotationKeyRefresh
 // annotation which can be used to detect changes, and a boolean indicating
 // whether the annotation was present.
@@ -95,3 +106,44 @@ func AbortPromotionAnnotationValue(annotations map[string]string) (*kargoapi.Abo
 	}
 	return &req, ok
 }
+
+// HasMigrationAnnotationValue checks if the AnnotationKeyMigrated annotation
+// is present in the provided annotations map and if it contains the specified
+// migration type as a key with a value of true.
+func HasMigrationAnnotationValue(obj client.Object, migrationType string) bool {
+	annotations := obj.GetAnnotations()
+	if annotations == nil {
+		return false
+	}
+	migrated, ok := annotations[kargoapi.AnnotationKeyMigrated]
+	if !ok {
+		return false
+	}
+	var migrations map[string]bool
+	if err := json.Unmarshal([]byte(migrated), &migrations); err != nil {
+		return false
+	}
+	return migrations[migrationType]
+}
+
+// AddMigrationAnnotationValue updates the AnnotationKeyMigrated annotation
+// in the provided annotations map to indicate that the specified migration
+// type has been performed. If the annotation does not exist, it is created.
+func AddMigrationAnnotationValue(obj client.Object, migrationType string) {
+	annotations := obj.GetAnnotations()
+	migrated, ok := annotations[kargoapi.AnnotationKeyMigrated]
+	if !ok {
+		migrated = "{}"
+	}
+	var migrations map[string]bool
+	if err := json.Unmarshal([]byte(migrated), &migrations); err != nil {
+		migrations = make(map[string]bool)
+	}
+	migrations[migrationType] = true
+	b, _ := json.Marshal(migrations)
+	if annotations == nil {
+		annotations = make(map[string]string, 1)
+	}
+	annotations[kargoapi.AnnotationKeyMigrated] = string(b)
+	obj.SetAnnotations(annotations)
+}
diff --git a/internal/controller/management/projects/projects.go b/internal/controller/management/projects/projects.go
index da1c05ae..2ead9e93 100644
--- a/internal/controller/management/projects/projects.go
+++ b/internal/controller/management/projects/projects.go
@@ -871,6 +871,10 @@ func (r *reconciler) migrateSpecToProjectConfig(
 		return false, nil
 	}
 
+	if api.HasMigrationAnnotationValue(project, api.MigratedProjectSpecToProjectConfig) {
+		return false, nil
+	}
+
 	if len(project.Spec.PromotionPolicies) != 0 { // nolint:staticcheck
 		projectCfg := &kargoapi.ProjectConfig{
 			ObjectMeta: metav1.ObjectMeta{
@@ -898,15 +902,15 @@ func (r *reconciler) migrateSpecToProjectConfig(
 		}
 	}
 
-	// Now remove the spec entirely.
-	project.Spec = nil // nolint:staticcheck
+	// Mark the Project as migrated. This will prevent the migration code from
+	// running again in the future.
+	api.AddMigrationAnnotationValue(project, api.MigratedProjectSpecToProjectConfig)
 	if err := r.client.Update(ctx, project); err != nil {
 		return false, fmt.Errorf(
-			"error updating Project %q to remove spec: %w",
+			"error updating Project %q to add migrated label: %w",
 			project.Name, err,
 		)
 	}
-
 	return true, nil
 }
 
diff --git a/internal/webhook/kubernetes/project/webhook.go b/internal/webhook/kubernetes/project/webhook.go
index d1748fea..a5bc5f3f 100644
--- a/internal/webhook/kubernetes/project/webhook.go
+++ b/internal/webhook/kubernetes/project/webhook.go
@@ -20,6 +20,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api"
 	"github.com/akuity/kargo/internal/logging"
 )
 
@@ -67,20 +68,12 @@ func (w *webhook) ValidateCreate(
 	project := obj.(*kargoapi.Project) // nolint: forcetypeassert
 
 	// TODO(hidde): Remove this when the deprecated Spec field is removed.
+	var warnings admission.Warnings
 	if project.Spec != nil { // nolint: staticcheck
-		return nil, apierrors.NewInvalid(
-			projectGroupKind,
+		warnings = append(warnings, fmt.Sprintf(
+			"ProjectSpec is deprecated: create a ProjectConfig named %q with the config instead",
 			project.Name,
-			field.ErrorList{
-				field.Forbidden(
-					field.NewPath("spec"),
-					fmt.Sprintf(
-						"deprecated field: create a ProjectConfig named %q with the config instead",
-						project.Name,
-					),
-				),
-			},
-		)
+		))
 	}
 
 	req, err := admission.RequestFromContext(ctx)
@@ -91,13 +84,13 @@ func (w *webhook) ValidateCreate(
 	}
 
 	if req.DryRun != nil && *req.DryRun {
-		return nil, nil
+		return warnings, nil
 	}
 
 	// We synchronously ensure the existence of a namespace with the same name as
 	// the Project because resources following the Project in a manifest are
 	// likely to be scoped to that namespace.
-	if err := w.ensureNamespace(ctx, project); err != nil {
+	if err = w.ensureNamespace(ctx, project); err != nil {
 		return nil, err
 	}
 
@@ -107,7 +100,7 @@ func (w *webhook) ValidateCreate(
 	// the Kargo API server carte blanche access these resources throughout the
 	// cluster. We do this synchronously because resources of these types are
 	// likely to follow the Project in a manifest.
-	return nil, w.ensureProjectAdminPermissions(ctx, project)
+	return warnings, w.ensureProjectAdminPermissions(ctx, project)
 }
 
 func (w *webhook) ValidateUpdate(
@@ -121,25 +114,33 @@ func (w *webhook) ValidateUpdate(
 	specPath := field.NewPath("spec")
 
 	// TODO(hidde): Remove this when the deprecated Spec field is removed.
+	var warnings admission.Warnings
 	if newProject.Spec != nil { // nolint: staticcheck
-		if !reflect.DeepEqual(oldProject.Spec, newProject.Spec) { // nolint: staticcheck
-			return nil, apierrors.NewInvalid(
-				projectGroupKind,
-				newProject.Name,
-				field.ErrorList{
-					field.Forbidden(
-						specPath,
-						fmt.Sprintf(
-							"deprecated field: create a ProjectConfig named %q with the config instead",
-							newProject.Name,
+		if api.HasMigrationAnnotationValue(newProject, api.MigratedProjectSpecToProjectConfig) {
+			if !reflect.DeepEqual(oldProject.Spec, newProject.Spec) { // nolint: staticcheck
+				return nil, apierrors.NewInvalid(
+					projectGroupKind,
+					newProject.Name,
+					field.ErrorList{
+						field.Forbidden(
+							specPath,
+							fmt.Sprintf(
+								"deprecated field: create a ProjectConfig named %q with the config instead",
+								newProject.Name,
+							),
 						),
-					),
-				},
-			)
+					},
+				)
+			}
 		}
+
+		// Warn the user that the ProjectSpec is deprecated.
+		warnings = append(warnings, fmt.Sprintf(
+			"ProjectSpec is deprecated: create a ProjectConfig named %q with the config instead", newProject.Name,
+		))
 	}
 
-	return nil, nil
+	return warnings, nil
 }
 
 func (w *webhook) ValidateDelete(



Test patch:
diff --git a/internal/api/annotations_test.go b/internal/api/annotations_test.go
index 14bea0f5..a1f2116b 100644
--- a/internal/api/annotations_test.go
+++ b/internal/api/annotations_test.go
@@ -128,3 +128,144 @@ func TestAbortPromotionAnnotationValue(t *testing.T) {
 		require.Nil(t, result)
 	})
 }
+
+func TestHasMigrationAnnotationValue(t *testing.T) {
+	mockObj := &kargoapi.Project{}
+
+	t.Run("has migration annotation with migration type true", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `{"migration1":true,"migration2":false}`,
+		})
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.True(t, result)
+	})
+
+	t.Run("has migration annotation with migration type false", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `{"migration1":true,"migration2":false}`,
+		})
+		result := HasMigrationAnnotationValue(obj, "migration2")
+		require.False(t, result)
+	})
+
+	t.Run("has migration annotation but migration type not present", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `{"migration1":true}`,
+		})
+		result := HasMigrationAnnotationValue(obj, "migration2")
+		require.False(t, result)
+	})
+
+	t.Run("does not have migration annotation", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			"other-annotation": "value",
+		})
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.False(t, result)
+	})
+
+	t.Run("annotations map is nil", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(nil)
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.False(t, result)
+	})
+
+	t.Run("has migration annotation with invalid JSON", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `invalid-json`,
+		})
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.False(t, result)
+	})
+
+	t.Run("has migration annotation with empty JSON object", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `{}`,
+		})
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.False(t, result)
+	})
+}
+
+func TestAddMigrationAnnotationValue(t *testing.T) {
+	mockObj := &kargoapi.Project{}
+
+	t.Run("adds migration to empty annotations map", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(make(map[string]string))
+		AddMigrationAnnotationValue(obj, "migration1")
+
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.True(t, result)
+		require.Contains(t, obj.GetAnnotations(), kargoapi.AnnotationKeyMigrated)
+	})
+
+	t.Run("adds migration to existing annotations without migration annotation", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			"other-annotation": "value",
+		})
+		AddMigrationAnnotationValue(obj, "migration1")
+
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.True(t, result)
+		require.Contains(t, obj.GetAnnotations(), kargoapi.AnnotationKeyMigrated)
+		require.Equal(t, "value", obj.GetAnnotations()["other-annotation"])
+	})
+
+	t.Run("adds migration to existing migration annotation", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `{"migration1":true}`,
+		})
+		AddMigrationAnnotationValue(obj, "migration2")
+
+		result1 := HasMigrationAnnotationValue(obj, "migration1")
+		result2 := HasMigrationAnnotationValue(obj, "migration2")
+		require.True(t, result1)
+		require.True(t, result2)
+	})
+
+	t.Run("adds migration when existing annotation has invalid JSON", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(map[string]string{
+			kargoapi.AnnotationKeyMigrated: `invalid-json`,
+		})
+		AddMigrationAnnotationValue(obj, "migration1")
+
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.True(t, result)
+	})
+
+	t.Run("adds same migration type multiple times", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(make(map[string]string))
+		AddMigrationAnnotationValue(obj, "migration1")
+		AddMigrationAnnotationValue(obj, "migration1")
+
+		result := HasMigrationAnnotationValue(obj, "migration1")
+		require.True(t, result)
+	})
+
+	t.Run("adds multiple different migrations", func(t *testing.T) {
+		obj := mockObj.DeepCopy()
+		obj.SetAnnotations(make(map[string]string))
+		AddMigrationAnnotationValue(obj, "migration1")
+		AddMigrationAnnotationValue(obj, "migration2")
+		AddMigrationAnnotationValue(obj, "migration3")
+
+		result1 := HasMigrationAnnotationValue(obj, "migration1")
+		result2 := HasMigrationAnnotationValue(obj, "migration2")
+		result3 := HasMigrationAnnotationValue(obj, "migration3")
+		require.True(t, result1)
+		require.True(t, result2)
+		require.True(t, result3)
+	})
+}
diff --git a/internal/controller/management/projects/projects_test.go b/internal/controller/management/projects/projects_test.go
index 76d98176..c5414a33 100644
--- a/internal/controller/management/projects/projects_test.go
+++ b/internal/controller/management/projects/projects_test.go
@@ -21,6 +21,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api"
 	"github.com/akuity/kargo/internal/conditions"
 )
 
@@ -1348,7 +1349,9 @@ func TestMigrateSpecToProjectConfig(t *testing.T) {
 				project := &kargoapi.Project{}
 				err = cl.Get(context.Background(), types.NamespacedName{Name: testProject}, project)
 				require.NoError(t, err)
-				require.Nil(t, project.Spec) // nolint:staticcheck
+				require.Contains(t, project.Annotations, kargoapi.AnnotationKeyMigrated)
+				require.True(t, api.HasMigrationAnnotationValue(project, api.MigratedProjectSpecToProjectConfig))
+				require.NotNil(t, project.Spec) // nolint:staticcheck
 				projCfg := &kargoapi.ProjectConfig{}
 				err = cl.Get(
 					context.Background(),
@@ -1397,7 +1400,9 @@ func TestMigrateSpecToProjectConfig(t *testing.T) {
 				project := &kargoapi.Project{}
 				err = cl.Get(context.Background(), types.NamespacedName{Name: testProject}, project)
 				require.NoError(t, err)
-				require.Nil(t, project.Spec) // nolint:staticcheck
+				require.Contains(t, project.Annotations, kargoapi.AnnotationKeyMigrated)
+				require.True(t, api.HasMigrationAnnotationValue(project, api.MigratedProjectSpecToProjectConfig))
+				require.NotNil(t, project.Spec) // nolint:staticcheck
 				projCfg := &kargoapi.ProjectConfig{}
 				err = cl.Get(
 					context.Background(),
diff --git a/internal/webhook/kubernetes/project/webhook_test.go b/internal/webhook/kubernetes/project/webhook_test.go
index 0cea4928..3869f362 100644
--- a/internal/webhook/kubernetes/project/webhook_test.go
+++ b/internal/webhook/kubernetes/project/webhook_test.go
@@ -18,6 +18,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/api"
 )
 
 func TestWebhookConfigFromEnv(t *testing.T) {
@@ -76,15 +77,10 @@ func Test_webhook_ValidateCreate(t *testing.T) {
 				Spec: &kargoapi.ProjectSpec{}, // nolint: staticcheck
 			},
 			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
-				assert.Empty(t, warnings)
-				require.Error(t, err)
-
-				var statusErr *apierrors.StatusError
-				require.True(t, errors.As(err, &statusErr))
-
-				assert.Equal(t, metav1.StatusReasonInvalid, statusErr.ErrStatus.Reason)
-				assert.Contains(t, statusErr.ErrStatus.Details.Causes[0].Message, "deprecated field")
-				assert.Equal(t, "spec", statusErr.ErrStatus.Details.Causes[0].Field)
+				assert.Len(t, warnings, 1)
+				assert.Contains(t, warnings[0], "ProjectSpec is deprecated")
+				assert.Contains(t, warnings[0], testProjectName)
+				assert.NoError(t, err) // Creation should succeed with warnings
 			},
 		},
 		{
@@ -132,6 +128,22 @@ func Test_webhook_ValidateCreate(t *testing.T) {
 				assert.NoError(t, err)
 			},
 		},
+		{
+			name: "dry run request with deprecated spec",
+			project: &kargoapi.Project{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: testProjectName,
+				},
+				Spec: &kargoapi.ProjectSpec{}, // nolint: staticcheck
+			},
+			isDryRun: true,
+			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
+				assert.Len(t, warnings, 1)
+				assert.Contains(t, warnings[0], "ProjectSpec is deprecated")
+				assert.Contains(t, warnings[0], testProjectName)
+				assert.NoError(t, err)
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -204,12 +216,14 @@ func Test_webhook_ValidateUpdate(t *testing.T) {
 				Spec: &kargoapi.ProjectSpec{}, // nolint: staticcheck
 			},
 			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
-				assert.Empty(t, warnings)
-				assert.NoError(t, err)
+				assert.Len(t, warnings, 1)
+				assert.Contains(t, warnings[0], "ProjectSpec is deprecated")
+				assert.Contains(t, warnings[0], testProjectName)
+				assert.NoError(t, err) // Should succeed with warnings
 			},
 		},
 		{
-			name: "changes to deprecated spec",
+			name: "changes to deprecated spec without migration annotation",
 			oldProject: &kargoapi.Project{
 				ObjectMeta: metav1.ObjectMeta{
 					Name: testProjectName,
@@ -237,7 +251,46 @@ func Test_webhook_ValidateUpdate(t *testing.T) {
 				},
 			},
 			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
-				assert.Empty(t, warnings)
+				assert.Len(t, warnings, 1)
+				assert.Contains(t, warnings[0], "ProjectSpec is deprecated")
+				assert.Contains(t, warnings[0], testProjectName)
+				assert.NoError(t, err) // Should succeed with warnings when no migration annotation
+			},
+		},
+		{
+			name: "changes to deprecated spec with migration annotation",
+			oldProject: &kargoapi.Project{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: testProjectName,
+				},
+				Spec: &kargoapi.ProjectSpec{ // nolint: staticcheck
+					PromotionPolicies: []kargoapi.PromotionPolicy{
+						{
+							Stage:                "test-stage",
+							AutoPromotionEnabled: false,
+						},
+					},
+				},
+			},
+			newProject: func() *kargoapi.Project {
+				p := &kargoapi.Project{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: testProjectName,
+					},
+					Spec: &kargoapi.ProjectSpec{ // nolint: staticcheck
+						PromotionPolicies: []kargoapi.PromotionPolicy{
+							{
+								Stage:                "test-stage",
+								AutoPromotionEnabled: true,
+							},
+						},
+					},
+				}
+				api.AddMigrationAnnotationValue(p, api.MigratedProjectSpecToProjectConfig)
+				return p
+			}(),
+			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
+				assert.Empty(t, warnings) // No warnings when there's an error
 				require.Error(t, err)
 
 				var statusErr *apierrors.StatusError
@@ -248,6 +301,46 @@ func Test_webhook_ValidateUpdate(t *testing.T) {
 				assert.Equal(t, "spec", statusErr.ErrStatus.Details.Causes[0].Field)
 			},
 		},
+		{
+			name: "no changes to deprecated spec with migration annotation",
+			oldProject: &kargoapi.Project{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:        testProjectName,
+					Annotations: make(map[string]string),
+				},
+				Spec: &kargoapi.ProjectSpec{ // nolint: staticcheck
+					PromotionPolicies: []kargoapi.PromotionPolicy{
+						{
+							Stage:                "test-stage",
+							AutoPromotionEnabled: false,
+						},
+					},
+				},
+			},
+			newProject: func() *kargoapi.Project {
+				p := &kargoapi.Project{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: testProjectName,
+					},
+					Spec: &kargoapi.ProjectSpec{ // nolint: staticcheck
+						PromotionPolicies: []kargoapi.PromotionPolicy{
+							{
+								Stage:                "test-stage",
+								AutoPromotionEnabled: false,
+							},
+						},
+					},
+				}
+				api.AddMigrationAnnotationValue(p, api.MigratedProjectSpecToProjectConfig)
+				return p
+			}(),
+			assertions: func(t *testing.T, warnings admission.Warnings, err error) {
+				assert.Len(t, warnings, 1)
+				assert.Contains(t, warnings[0], "ProjectSpec is deprecated")
+				assert.Contains(t, warnings[0], testProjectName)
+				assert.NoError(t, err) // Should succeed when no changes to spec
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -354,15 +447,6 @@ func Test_webhook_ensureNamespace(t *testing.T) {
 
 			err := w.ensureNamespace(context.Background(), tt.project)
 			tt.assertions(t, err)
-
-			// Check namespace creation in the first test case
-			if tt.name == "namespace does not exist, should create" {
-				ns := &corev1.Namespace{}
-				err := c.Get(context.Background(), client.ObjectKey{Name: testProjectName}, ns)
-				assert.NoError(t, err)
-				assert.Equal(t, kargoapi.LabelTrueValue, ns.Labels[kargoapi.ProjectLabelKey])
-				assert.Contains(t, ns.Finalizers, kargoapi.FinalizerName)
-			}
 		})
 	}
 }
@@ -424,7 +508,6 @@ func Test_webhook_ensureProjectAdminPermissions(t *testing.T) {
 					Namespace: testProjectName,
 				}, rb)
 				assert.NoError(t, err)
-				assert.Equal(t, "kargo-project-admin", rb.RoleRef.Name)
 				assert.Len(t, rb.Subjects, 2)
 				assert.Equal(t, "kargo-api", rb.Subjects[0].Name)
 				assert.Equal(t, "kargo-admin", rb.Subjects[1].Name)