From 8f86fbae565eeda4273bb8528710bfd6ef16cfcf Mon Sep 17 00:00:00 2001
From: Camila Macedo <camilamacedo86@gmail.com>
Date: Thu, 31 Oct 2024 11:38:13 +0000
Subject: [PATCH] fix: optimize and improve testdata scaffolding for webhook
 conversion

- Set  for all steps to speed up project generation
- Adjust mock data to include webhook conversion scenarios

NOTE: This update addresses bug fixes and enhancements requiring these mock datasets, including future support for hub-and-spoke webhook scaffolding and Helm plugin compatibility. Ensuring comprehensive coverage in testdata helps validate these scenarios effectively.
---
 test/testdata/generate.sh                     |  48 ++++-
 testdata/project-v4-multigroup/PROJECT        |  23 ++-
 .../api/example.com/v1/groupversion_info.go   |  36 ++++
 .../api/example.com/v1/wordpress_types.go     |  65 +++++++
 .../example.com/v1/zz_generated.deepcopy.go   | 114 ++++++++++++
 .../api/example.com/v2/groupversion_info.go   |  36 ++++
 .../api/example.com/v2/wordpress_types.go     |  64 +++++++
 .../example.com/v2/zz_generated.deepcopy.go   | 114 ++++++++++++
 testdata/project-v4-multigroup/cmd/main.go    |  27 ++-
 ...ample.com.testproject.org_wordpresses.yaml |  92 ++++++++++
 .../config/crd/kustomization.yaml             |   4 +-
 ...injection_in_example.com_wordpresses.yaml} |   2 +-
 ...> webhook_in_example.com_wordpresses.yaml} |   2 +-
 .../example.com_wordpress_editor_role.yaml    |  27 +++
 .../example.com_wordpress_viewer_role.yaml    |  23 +++
 .../config/rbac/kustomization.yaml            |   2 +
 .../config/rbac/role.yaml                     |   3 +
 .../samples/example.com_v1_wordpress.yaml     |   9 +
 .../samples/example.com_v2_wordpress.yaml     |   9 +
 .../config/samples/kustomization.yaml         |   2 +
 .../project-v4-multigroup/dist/install.yaml   | 165 ++++++++++++++++--
 .../controller/example.com/suite_test.go      |   4 +
 .../example.com/wordpress_controller.go       |  63 +++++++
 .../example.com/wordpress_controller_test.go  |  84 +++++++++
 .../example.com/v1/wordpress_webhook.go       |  36 ++++
 .../example.com/v1/wordpress_webhook_test.go  |  55 ++++++
 testdata/project-v4-with-plugins/PROJECT      |  20 +++
 .../api/v1/groupversion_info.go               |  36 ++++
 .../api/v1/wordpress_types.go                 |  65 +++++++
 .../api/v1/zz_generated.deepcopy.go           | 114 ++++++++++++
 .../api/v2/groupversion_info.go               |  36 ++++
 .../api/v2/wordpress_types.go                 |  64 +++++++
 .../api/v2/zz_generated.deepcopy.go           | 114 ++++++++++++
 testdata/project-v4-with-plugins/cmd/main.go  |  19 ++
 ...ample.com.testproject.org_wordpresses.yaml |  92 ++++++++++
 .../config/crd/kustomization.yaml             |   3 +
 .../patches/cainjection_in_wordpresses.yaml   |   7 +
 .../crd/patches/webhook_in_wordpresses.yaml   |  16 ++
 .../config/rbac/kustomization.yaml            |   2 +
 .../config/rbac/role.yaml                     |   3 +
 .../config/rbac/wordpress_editor_role.yaml    |  27 +++
 .../config/rbac/wordpress_viewer_role.yaml    |  23 +++
 .../samples/example.com_v1_wordpress.yaml     |   9 +
 .../samples/example.com_v2_wordpress.yaml     |   9 +
 .../config/samples/kustomization.yaml         |   2 +
 .../project-v4-with-plugins/dist/install.yaml | 155 ++++++++++++++++
 .../internal/controller/suite_test.go         |   4 +
 .../controller/wordpress_controller.go        |  63 +++++++
 .../controller/wordpress_controller_test.go   |  84 +++++++++
 .../internal/webhook/v1/wordpress_webhook.go} |  12 +-
 .../webhook/v1/wordpress_webhook_test.go}     |  18 +-
 testdata/project-v4/PROJECT                   |   8 +
 testdata/project-v4/api/v1/firstmate_types.go |   3 +-
 testdata/project-v4/api/v2/firstmate_types.go |  64 +++++++
 .../project-v4/api/v2/groupversion_info.go    |  36 ++++
 .../api/v2/zz_generated.deepcopy.go           | 114 ++++++++++++
 testdata/project-v4/cmd/main.go               |   2 +
 .../crew.testproject.org_firstmates.yaml      |  38 ++++
 .../config/samples/crew_v2_firstmate.yaml     |   9 +
 .../config/samples/kustomization.yaml         |   1 +
 testdata/project-v4/dist/install.yaml         |  38 ++++
 61 files changed, 2371 insertions(+), 48 deletions(-)
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v1/groupversion_info.go
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v1/wordpress_types.go
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v1/zz_generated.deepcopy.go
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v2/groupversion_info.go
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v2/wordpress_types.go
 create mode 100644 testdata/project-v4-multigroup/api/example.com/v2/zz_generated.deepcopy.go
 create mode 100644 testdata/project-v4-multigroup/config/crd/bases/example.com.testproject.org_wordpresses.yaml
 rename testdata/project-v4-multigroup/config/crd/patches/{cainjection_in_ship_frigates.yaml => cainjection_in_example.com_wordpresses.yaml} (83%)
 rename testdata/project-v4-multigroup/config/crd/patches/{webhook_in_ship_frigates.yaml => webhook_in_example.com_wordpresses.yaml} (88%)
 create mode 100644 testdata/project-v4-multigroup/config/rbac/example.com_wordpress_editor_role.yaml
 create mode 100644 testdata/project-v4-multigroup/config/rbac/example.com_wordpress_viewer_role.yaml
 create mode 100644 testdata/project-v4-multigroup/config/samples/example.com_v1_wordpress.yaml
 create mode 100644 testdata/project-v4-multigroup/config/samples/example.com_v2_wordpress.yaml
 create mode 100644 testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller.go
 create mode 100644 testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller_test.go
 create mode 100644 testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook.go
 create mode 100644 testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook_test.go
 create mode 100644 testdata/project-v4-with-plugins/api/v1/groupversion_info.go
 create mode 100644 testdata/project-v4-with-plugins/api/v1/wordpress_types.go
 create mode 100644 testdata/project-v4-with-plugins/api/v1/zz_generated.deepcopy.go
 create mode 100644 testdata/project-v4-with-plugins/api/v2/groupversion_info.go
 create mode 100644 testdata/project-v4-with-plugins/api/v2/wordpress_types.go
 create mode 100644 testdata/project-v4-with-plugins/api/v2/zz_generated.deepcopy.go
 create mode 100644 testdata/project-v4-with-plugins/config/crd/bases/example.com.testproject.org_wordpresses.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/crd/patches/cainjection_in_wordpresses.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/crd/patches/webhook_in_wordpresses.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/rbac/wordpress_editor_role.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/rbac/wordpress_viewer_role.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/samples/example.com_v1_wordpress.yaml
 create mode 100644 testdata/project-v4-with-plugins/config/samples/example.com_v2_wordpress.yaml
 create mode 100644 testdata/project-v4-with-plugins/internal/controller/wordpress_controller.go
 create mode 100644 testdata/project-v4-with-plugins/internal/controller/wordpress_controller_test.go
 rename testdata/{project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook.go => project-v4-with-plugins/internal/webhook/v1/wordpress_webhook.go} (68%)
 rename testdata/{project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook_test.go => project-v4-with-plugins/internal/webhook/v1/wordpress_webhook_test.go} (76%)
 create mode 100644 testdata/project-v4/api/v2/firstmate_types.go
 create mode 100644 testdata/project-v4/api/v2/groupversion_info.go
 create mode 100644 testdata/project-v4/api/v2/zz_generated.deepcopy.go
 create mode 100644 testdata/project-v4/config/samples/crew_v2_firstmate.yaml

diff --git a/test/testdata/generate.sh b/test/testdata/generate.sh
index 4d96fb2abc9..e0c4c754855 100755
--- a/test/testdata/generate.sh
+++ b/test/testdata/generate.sh
@@ -31,6 +31,17 @@ function scaffold_test_project {
   rm -rf $testdata_dir/$project/*
   pushd $testdata_dir/$project
 
+  # Define the sed command based on the OS
+  if [[ "$OSTYPE" == "darwin"* ]]; then
+    # macOS sed syntax
+    sed_storage_version="sed -i '' '43i\\
+    // +kubebuilder:storageversion\\
+    // +kubebuilder:conversion:hub'"
+  else
+    # Linux sed syntax
+    sed_storage_version="sed -i '43i // +kubebuilder:storageversion\n// +kubebuilder:conversion:hub'"
+  fi
+
   header_text "Generating project ${project} with flags: ${init_flags}"
   go mod init sigs.k8s.io/kubebuilder/testdata/$project  # our repo autodetection will traverse up to the kb module if we don't do this
   header_text "Initializing project ..."
@@ -40,9 +51,16 @@ function scaffold_test_project {
     header_text 'Creating APIs ...'
     $kb create api --group crew --version v1 --kind Captain --controller=true --resource=true --make=false
     $kb create api --group crew --version v1 --kind Captain --controller=true --resource=true --make=false --force
-    $kb create webhook --group crew --version v1 --kind Captain --defaulting --programmatic-validation
+    $kb create webhook --group crew --version v1 --kind Captain --defaulting --programmatic-validation --make=false
+    
+    # Create API to test conversion from v1 to v2
     $kb create api --group crew --version v1 --kind FirstMate --controller=true --resource=true --make=false
-    $kb create webhook --group crew --version v1 --kind FirstMate --conversion
+    $kb create api --group crew --version v2 --kind FirstMate --controller=false --resource=true --make=false
+    $kb create webhook --group crew --version v1 --kind FirstMate --conversion --make=false
+    # TODO: Remove it when we have the hub and spoke scaffolded by Kubebuilder
+    # Apply the sed command based on project type
+    eval "$sed_storage_version api/v1/firstmate_types.go"
+    
     $kb create api --group crew --version v1 --kind Admiral --plural=admirales --controller=true --resource=true --namespaced=false --make=false
     $kb create webhook --group crew --version v1 --kind Admiral --plural=admirales --defaulting
     # Controller for External types
@@ -59,14 +77,13 @@ function scaffold_test_project {
 
     header_text 'Creating APIs ...'
     $kb create api --group crew --version v1 --kind Captain --controller=true --resource=true --make=false
-    $kb create webhook --group crew --version v1 --kind Captain --defaulting --programmatic-validation
+    $kb create webhook --group crew --version v1 --kind Captain --defaulting --programmatic-validation --make=false
 
     $kb create api --group ship --version v1beta1 --kind Frigate --controller=true --resource=true --make=false
-    $kb create webhook --group ship --version v1beta1 --kind Frigate --conversion
     $kb create api --group ship --version v1 --kind Destroyer --controller=true --resource=true --namespaced=false --make=false
-    $kb create webhook --group ship --version v1 --kind Destroyer --defaulting
+    $kb create webhook --group ship --version v1 --kind Destroyer --defaulting --make=false
     $kb create api --group ship --version v2alpha1 --kind Cruiser --controller=true --resource=true --namespaced=false --make=false
-    $kb create webhook --group ship --version v2alpha1 --kind Cruiser --programmatic-validation
+    $kb create webhook --group ship --version v2alpha1 --kind Cruiser --programmatic-validation --make=false
 
     $kb create api --group sea-creatures --version v1beta1 --kind Kraken --controller=true --resource=true --make=false
     $kb create api --group sea-creatures --version v1beta2 --kind Leviathan --controller=true --resource=true --make=false
@@ -80,7 +97,7 @@ function scaffold_test_project {
     # Webhook for External types
     $kb create webhook --group "cert-manager" --version v1 --kind Issuer --defaulting --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 --external-api-domain=io
     # Webhook for Core type
-    $kb create webhook --group core --version v1 --kind Pod --programmatic-validation
+    $kb create webhook --group core --version v1 --kind Pod --programmatic-validation --make=false
   fi
 
   if [[ $project =~ multigroup ]] || [[ $project =~ with-plugins ]] ; then
@@ -88,7 +105,22 @@ function scaffold_test_project {
     header_text 'Creating APIs with deploy-image plugin ...'
     $kb create api --group example.com --version v1alpha1 --kind Memcached --image=memcached:1.6.26-alpine3.19 --image-container-command="memcached,--memory-limit=64,-o,modern,-v" --image-container-port="11211" --run-as-user="1001" --plugins="deploy-image/v1-alpha" --make=false
     $kb create api --group example.com --version v1alpha1 --kind Busybox --image=busybox:1.36.1 --plugins="deploy-image/v1-alpha" --make=false
-    $kb create webhook --group example.com --version v1alpha1 --kind Memcached --programmatic-validation
+    # Create only validation webhook for Memcached
+    $kb create webhook --group example.com --version v1alpha1 --kind Memcached --programmatic-validation --make=false
+    # Create API to check webhook --conversion from v1 to v2
+    $kb create api --group example.com --version v1 --kind Wordpress --controller=true --resource=true  --make=false
+    $kb create api --group example.com --version v2 --kind Wordpress --controller=false --resource=true  --make=false
+    $kb create webhook --group example.com --version v1 --kind Wordpress --conversion --make=false
+    
+    # TODO: Remove it when we have the hub and spoke scaffolded by Kubebuilder
+    # Apply the sed command based on project type
+    if [[ $project =~ multigroup ]]; then
+      eval "$sed_storage_version api/example.com/v1/wordpress_types.go"
+    fi
+    if [[ $project =~ with-plugins ]]; then
+      eval "$sed_storage_version api/v1/wordpress_types.go"
+    fi
+    
     header_text 'Editing project with Grafana plugin ...'
     $kb edit --plugins=grafana.kubebuilder.io/v1-alpha
   fi
diff --git a/testdata/project-v4-multigroup/PROJECT b/testdata/project-v4-multigroup/PROJECT
index 9a93ab257f1..54359febb7b 100644
--- a/testdata/project-v4-multigroup/PROJECT
+++ b/testdata/project-v4-multigroup/PROJECT
@@ -50,9 +50,6 @@ resources:
   kind: Frigate
   path: sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/ship/v1beta1
   version: v1beta1
-  webhooks:
-    conversion: true
-    webhookVersion: v1
 - api:
     crdVersion: v1
   controller: true
@@ -171,4 +168,24 @@ resources:
   kind: Busybox
   path: sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: testproject.org
+  group: example.com
+  kind: Wordpress
+  path: sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1
+  version: v1
+  webhooks:
+    conversion: true
+    webhookVersion: v1
+- api:
+    crdVersion: v1
+    namespaced: true
+  domain: testproject.org
+  group: example.com
+  kind: Wordpress
+  path: sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v2
+  version: v2
 version: "3"
diff --git a/testdata/project-v4-multigroup/api/example.com/v1/groupversion_info.go b/testdata/project-v4-multigroup/api/example.com/v1/groupversion_info.go
new file mode 100644
index 00000000000..f6ea9c38602
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v1/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v1 contains API Schema definitions for the example.com v1 API group.
+// +kubebuilder:object:generate=true
+// +groupName=example.com.testproject.org
+package v1
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+	// GroupVersion is group version used to register these objects.
+	GroupVersion = schema.GroupVersion{Group: "example.com.testproject.org", Version: "v1"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/testdata/project-v4-multigroup/api/example.com/v1/wordpress_types.go b/testdata/project-v4-multigroup/api/example.com/v1/wordpress_types.go
new file mode 100644
index 00000000000..0304ca8b4cf
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v1/wordpress_types.go
@@ -0,0 +1,65 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// WordpressSpec defines the desired state of Wordpress.
+type WordpressSpec struct {
+	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+
+	// Foo is an example field of Wordpress. Edit wordpress_types.go to remove/update
+	Foo string `json:"foo,omitempty"`
+}
+
+// WordpressStatus defines the observed state of Wordpress.
+type WordpressStatus struct {
+	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:storageversion
+// +kubebuilder:conversion:hub
+// Wordpress is the Schema for the wordpresses API.
+type Wordpress struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   WordpressSpec   `json:"spec,omitempty"`
+	Status WordpressStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// WordpressList contains a list of Wordpress.
+type WordpressList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Wordpress `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Wordpress{}, &WordpressList{})
+}
diff --git a/testdata/project-v4-multigroup/api/example.com/v1/zz_generated.deepcopy.go b/testdata/project-v4-multigroup/api/example.com/v1/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..8ef996e16d1
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v1/zz_generated.deepcopy.go
@@ -0,0 +1,114 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Wordpress) DeepCopyInto(out *Wordpress) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wordpress.
+func (in *Wordpress) DeepCopy() *Wordpress {
+	if in == nil {
+		return nil
+	}
+	out := new(Wordpress)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Wordpress) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressList) DeepCopyInto(out *WordpressList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Wordpress, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressList.
+func (in *WordpressList) DeepCopy() *WordpressList {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *WordpressList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressSpec) DeepCopyInto(out *WordpressSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressSpec.
+func (in *WordpressSpec) DeepCopy() *WordpressSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressStatus) DeepCopyInto(out *WordpressStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressStatus.
+func (in *WordpressStatus) DeepCopy() *WordpressStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/testdata/project-v4-multigroup/api/example.com/v2/groupversion_info.go b/testdata/project-v4-multigroup/api/example.com/v2/groupversion_info.go
new file mode 100644
index 00000000000..fbb9302bda5
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v2/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v2 contains API Schema definitions for the example.com v2 API group.
+// +kubebuilder:object:generate=true
+// +groupName=example.com.testproject.org
+package v2
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+	// GroupVersion is group version used to register these objects.
+	GroupVersion = schema.GroupVersion{Group: "example.com.testproject.org", Version: "v2"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/testdata/project-v4-multigroup/api/example.com/v2/wordpress_types.go b/testdata/project-v4-multigroup/api/example.com/v2/wordpress_types.go
new file mode 100644
index 00000000000..5a1d1ba6a68
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v2/wordpress_types.go
@@ -0,0 +1,64 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// WordpressSpec defines the desired state of Wordpress.
+type WordpressSpec struct {
+	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+
+	// Foo is an example field of Wordpress. Edit wordpress_types.go to remove/update
+	Foo string `json:"foo,omitempty"`
+}
+
+// WordpressStatus defines the observed state of Wordpress.
+type WordpressStatus struct {
+	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// Wordpress is the Schema for the wordpresses API.
+type Wordpress struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   WordpressSpec   `json:"spec,omitempty"`
+	Status WordpressStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// WordpressList contains a list of Wordpress.
+type WordpressList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Wordpress `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Wordpress{}, &WordpressList{})
+}
diff --git a/testdata/project-v4-multigroup/api/example.com/v2/zz_generated.deepcopy.go b/testdata/project-v4-multigroup/api/example.com/v2/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..069e5cfd811
--- /dev/null
+++ b/testdata/project-v4-multigroup/api/example.com/v2/zz_generated.deepcopy.go
@@ -0,0 +1,114 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v2
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Wordpress) DeepCopyInto(out *Wordpress) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wordpress.
+func (in *Wordpress) DeepCopy() *Wordpress {
+	if in == nil {
+		return nil
+	}
+	out := new(Wordpress)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Wordpress) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressList) DeepCopyInto(out *WordpressList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Wordpress, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressList.
+func (in *WordpressList) DeepCopy() *WordpressList {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *WordpressList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressSpec) DeepCopyInto(out *WordpressSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressSpec.
+func (in *WordpressSpec) DeepCopy() *WordpressSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressStatus) DeepCopyInto(out *WordpressStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressStatus.
+func (in *WordpressStatus) DeepCopy() *WordpressStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/testdata/project-v4-multigroup/cmd/main.go b/testdata/project-v4-multigroup/cmd/main.go
index 0fe9c4afead..9444e6e0db6 100644
--- a/testdata/project-v4-multigroup/cmd/main.go
+++ b/testdata/project-v4-multigroup/cmd/main.go
@@ -38,7 +38,9 @@ import (
 	certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
 
 	crewv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/crew/v1"
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
 	examplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1alpha1"
+	examplecomv2 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v2"
 	fizv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/fiz/v1"
 	foopolicyv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/foo.policy/v1"
 	foov1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/foo/v1"
@@ -59,9 +61,9 @@ import (
 	webhookcertmanagerv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/cert-manager/v1"
 	webhookcorev1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/core/v1"
 	webhookcrewv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/crew/v1"
+	webhookexamplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/example.com/v1"
 	webhookexamplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/example.com/v1alpha1"
 	webhookshipv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/ship/v1"
-	webhookshipv1beta1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/ship/v1beta1"
 	webhookshipv2alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/internal/webhook/ship/v2alpha1"
 	// +kubebuilder:scaffold:imports
 )
@@ -85,6 +87,8 @@ func init() {
 	utilruntime.Must(fizv1.AddToScheme(scheme))
 	utilruntime.Must(certmanagerv1.AddToScheme(scheme))
 	utilruntime.Must(examplecomv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(examplecomv1.AddToScheme(scheme))
+	utilruntime.Must(examplecomv2.AddToScheme(scheme))
 	// +kubebuilder:scaffold:scheme
 }
 
@@ -199,13 +203,6 @@ func main() {
 		setupLog.Error(err, "unable to create controller", "controller", "Frigate")
 		os.Exit(1)
 	}
-	// nolint:goconst
-	if os.Getenv("ENABLE_WEBHOOKS") != "false" {
-		if err = webhookshipv1beta1.SetupFrigateWebhookWithManager(mgr); err != nil {
-			setupLog.Error(err, "unable to create webhook", "webhook", "Frigate")
-			os.Exit(1)
-		}
-	}
 	if err = (&shipcontroller.DestroyerReconciler{
 		Client: mgr.GetClient(),
 		Scheme: mgr.GetScheme(),
@@ -320,6 +317,20 @@ func main() {
 			os.Exit(1)
 		}
 	}
+	if err = (&examplecomcontroller.WordpressReconciler{
+		Client: mgr.GetClient(),
+		Scheme: mgr.GetScheme(),
+	}).SetupWithManager(mgr); err != nil {
+		setupLog.Error(err, "unable to create controller", "controller", "Wordpress")
+		os.Exit(1)
+	}
+	// nolint:goconst
+	if os.Getenv("ENABLE_WEBHOOKS") != "false" {
+		if err = webhookexamplecomv1.SetupWordpressWebhookWithManager(mgr); err != nil {
+			setupLog.Error(err, "unable to create webhook", "webhook", "Wordpress")
+			os.Exit(1)
+		}
+	}
 	// +kubebuilder:scaffold:builder
 
 	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
diff --git a/testdata/project-v4-multigroup/config/crd/bases/example.com.testproject.org_wordpresses.yaml b/testdata/project-v4-multigroup/config/crd/bases/example.com.testproject.org_wordpresses.yaml
new file mode 100644
index 00000000000..0e059f9cc26
--- /dev/null
+++ b/testdata/project-v4-multigroup/config/crd/bases/example.com.testproject.org_wordpresses.yaml
@@ -0,0 +1,92 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.4
+  name: wordpresses.example.com.testproject.org
+spec:
+  group: example.com.testproject.org
+  names:
+    kind: Wordpress
+    listKind: WordpressList
+    plural: wordpresses
+    singular: wordpress
+  scope: Namespaced
+  versions:
+  - name: v1
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
diff --git a/testdata/project-v4-multigroup/config/crd/kustomization.yaml b/testdata/project-v4-multigroup/config/crd/kustomization.yaml
index 6b362727dd7..193cb75cbfb 100644
--- a/testdata/project-v4-multigroup/config/crd/kustomization.yaml
+++ b/testdata/project-v4-multigroup/config/crd/kustomization.yaml
@@ -13,16 +13,17 @@ resources:
 - bases/fiz.testproject.org_bars.yaml
 - bases/example.com.testproject.org_memcacheds.yaml
 - bases/example.com.testproject.org_busyboxes.yaml
+- bases/example.com.testproject.org_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patches:
 # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
 # patches here are for enabling the conversion webhook for each CRD
 - path: patches/webhook_in_crew_captains.yaml
-- path: patches/webhook_in_ship_frigates.yaml
 - path: patches/webhook_in_ship_destroyers.yaml
 - path: patches/webhook_in_ship_cruisers.yaml
 - path: patches/webhook_in_example.com_memcacheds.yaml
+- path: patches/webhook_in_example.com_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizewebhookpatch
 
 # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix.
@@ -38,6 +39,7 @@ patches:
 #- path: patches/cainjection_in_fiz_bars.yaml
 #- path: patches/cainjection_in_example.com_memcacheds.yaml
 #- path: patches/cainjection_in_example.com_busyboxes.yaml
+#- path: patches/cainjection_in_example.com_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizecainjectionpatch
 
 # [WEBHOOK] To enable webhook, uncomment the following section
diff --git a/testdata/project-v4-multigroup/config/crd/patches/cainjection_in_ship_frigates.yaml b/testdata/project-v4-multigroup/config/crd/patches/cainjection_in_example.com_wordpresses.yaml
similarity index 83%
rename from testdata/project-v4-multigroup/config/crd/patches/cainjection_in_ship_frigates.yaml
rename to testdata/project-v4-multigroup/config/crd/patches/cainjection_in_example.com_wordpresses.yaml
index d4acb9d24c1..94469071ccf 100644
--- a/testdata/project-v4-multigroup/config/crd/patches/cainjection_in_ship_frigates.yaml
+++ b/testdata/project-v4-multigroup/config/crd/patches/cainjection_in_example.com_wordpresses.yaml
@@ -4,4 +4,4 @@ kind: CustomResourceDefinition
 metadata:
   annotations:
     cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME
-  name: frigates.ship.testproject.org
+  name: wordpresses.example.com.testproject.org
diff --git a/testdata/project-v4-multigroup/config/crd/patches/webhook_in_ship_frigates.yaml b/testdata/project-v4-multigroup/config/crd/patches/webhook_in_example.com_wordpresses.yaml
similarity index 88%
rename from testdata/project-v4-multigroup/config/crd/patches/webhook_in_ship_frigates.yaml
rename to testdata/project-v4-multigroup/config/crd/patches/webhook_in_example.com_wordpresses.yaml
index cdc5078ae71..e335002e320 100644
--- a/testdata/project-v4-multigroup/config/crd/patches/webhook_in_ship_frigates.yaml
+++ b/testdata/project-v4-multigroup/config/crd/patches/webhook_in_example.com_wordpresses.yaml
@@ -2,7 +2,7 @@
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
-  name: frigates.ship.testproject.org
+  name: wordpresses.example.com.testproject.org
 spec:
   conversion:
     strategy: Webhook
diff --git a/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_editor_role.yaml b/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_editor_role.yaml
new file mode 100644
index 00000000000..952b9f79f16
--- /dev/null
+++ b/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_editor_role.yaml
@@ -0,0 +1,27 @@
+# permissions for end users to edit wordpresses.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-multigroup
+    app.kubernetes.io/managed-by: kustomize
+  name: example.com-wordpress-editor-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
diff --git a/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_viewer_role.yaml b/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_viewer_role.yaml
new file mode 100644
index 00000000000..bfc87af1aff
--- /dev/null
+++ b/testdata/project-v4-multigroup/config/rbac/example.com_wordpress_viewer_role.yaml
@@ -0,0 +1,23 @@
+# permissions for end users to view wordpresses.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-multigroup
+    app.kubernetes.io/managed-by: kustomize
+  name: example.com-wordpress-viewer-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
diff --git a/testdata/project-v4-multigroup/config/rbac/kustomization.yaml b/testdata/project-v4-multigroup/config/rbac/kustomization.yaml
index bbf5c747bb5..bea2c901ef8 100644
--- a/testdata/project-v4-multigroup/config/rbac/kustomization.yaml
+++ b/testdata/project-v4-multigroup/config/rbac/kustomization.yaml
@@ -22,6 +22,8 @@ resources:
 # default, aiding admins in cluster management. Those roles are
 # not used by the Project itself. You can comment the following lines
 # if you do not want those helpers be installed with your Project.
+- example.com_wordpress_editor_role.yaml
+- example.com_wordpress_viewer_role.yaml
 - example.com_busybox_editor_role.yaml
 - example.com_busybox_viewer_role.yaml
 - example.com_memcached_editor_role.yaml
diff --git a/testdata/project-v4-multigroup/config/rbac/role.yaml b/testdata/project-v4-multigroup/config/rbac/role.yaml
index f8e24f77f46..a5c1f7ef0bd 100644
--- a/testdata/project-v4-multigroup/config/rbac/role.yaml
+++ b/testdata/project-v4-multigroup/config/rbac/role.yaml
@@ -102,6 +102,7 @@ rules:
   resources:
   - busyboxes
   - memcacheds
+  - wordpresses
   verbs:
   - create
   - delete
@@ -115,6 +116,7 @@ rules:
   resources:
   - busyboxes/finalizers
   - memcacheds/finalizers
+  - wordpresses/finalizers
   verbs:
   - update
 - apiGroups:
@@ -122,6 +124,7 @@ rules:
   resources:
   - busyboxes/status
   - memcacheds/status
+  - wordpresses/status
   verbs:
   - get
   - patch
diff --git a/testdata/project-v4-multigroup/config/samples/example.com_v1_wordpress.yaml b/testdata/project-v4-multigroup/config/samples/example.com_v1_wordpress.yaml
new file mode 100644
index 00000000000..33c5dc34ccd
--- /dev/null
+++ b/testdata/project-v4-multigroup/config/samples/example.com_v1_wordpress.yaml
@@ -0,0 +1,9 @@
+apiVersion: example.com.testproject.org/v1
+kind: Wordpress
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-multigroup
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-sample
+spec:
+  # TODO(user): Add fields here
diff --git a/testdata/project-v4-multigroup/config/samples/example.com_v2_wordpress.yaml b/testdata/project-v4-multigroup/config/samples/example.com_v2_wordpress.yaml
new file mode 100644
index 00000000000..7b97dc151fc
--- /dev/null
+++ b/testdata/project-v4-multigroup/config/samples/example.com_v2_wordpress.yaml
@@ -0,0 +1,9 @@
+apiVersion: example.com.testproject.org/v2
+kind: Wordpress
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-multigroup
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-sample
+spec:
+  # TODO(user): Add fields here
diff --git a/testdata/project-v4-multigroup/config/samples/kustomization.yaml b/testdata/project-v4-multigroup/config/samples/kustomization.yaml
index cffe4bc820a..703c10a4a61 100644
--- a/testdata/project-v4-multigroup/config/samples/kustomization.yaml
+++ b/testdata/project-v4-multigroup/config/samples/kustomization.yaml
@@ -11,4 +11,6 @@ resources:
 - fiz_v1_bar.yaml
 - example.com_v1alpha1_memcached.yaml
 - example.com_v1alpha1_busybox.yaml
+- example.com_v1_wordpress.yaml
+- example.com_v2_wordpress.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples
diff --git a/testdata/project-v4-multigroup/dist/install.yaml b/testdata/project-v4-multigroup/dist/install.yaml
index b6f36443784..44294bdc330 100644
--- a/testdata/project-v4-multigroup/dist/install.yaml
+++ b/testdata/project-v4-multigroup/dist/install.yaml
@@ -430,16 +430,6 @@ metadata:
     controller-gen.kubebuilder.io/version: v0.16.4
   name: frigates.ship.testproject.org
 spec:
-  conversion:
-    strategy: Webhook
-    webhook:
-      clientConfig:
-        service:
-          name: project-v4-multigroup-webhook-service
-          namespace: project-v4-multigroup-system
-          path: /convert
-      conversionReviewVersions:
-      - v1
   group: ship.testproject.org
   names:
     kind: Frigate
@@ -780,6 +770,108 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.4
+  name: wordpresses.example.com.testproject.org
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          name: project-v4-multigroup-webhook-service
+          namespace: project-v4-multigroup-system
+          path: /convert
+      conversionReviewVersions:
+      - v1
+  group: example.com.testproject.org
+  names:
+    kind: Wordpress
+    listKind: WordpressList
+    plural: wordpresses
+    singular: wordpress
+  scope: Namespaced
+  versions:
+  - name: v1
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -982,6 +1074,56 @@ rules:
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/managed-by: kustomize
+    app.kubernetes.io/name: project-v4-multigroup
+  name: project-v4-multigroup-example.com-wordpress-editor-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/managed-by: kustomize
+    app.kubernetes.io/name: project-v4-multigroup
+  name: project-v4-multigroup-example.com-wordpress-viewer-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
 metadata:
   labels:
     app.kubernetes.io/managed-by: kustomize
@@ -1233,6 +1375,7 @@ rules:
   resources:
   - busyboxes
   - memcacheds
+  - wordpresses
   verbs:
   - create
   - delete
@@ -1246,6 +1389,7 @@ rules:
   resources:
   - busyboxes/finalizers
   - memcacheds/finalizers
+  - wordpresses/finalizers
   verbs:
   - update
 - apiGroups:
@@ -1253,6 +1397,7 @@ rules:
   resources:
   - busyboxes/status
   - memcacheds/status
+  - wordpresses/status
   verbs:
   - get
   - patch
diff --git a/testdata/project-v4-multigroup/internal/controller/example.com/suite_test.go b/testdata/project-v4-multigroup/internal/controller/example.com/suite_test.go
index 316975ca415..4aae9289791 100644
--- a/testdata/project-v4-multigroup/internal/controller/example.com/suite_test.go
+++ b/testdata/project-v4-multigroup/internal/controller/example.com/suite_test.go
@@ -33,6 +33,7 @@ import (
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
 	examplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1alpha1"
 	// +kubebuilder:scaffold:imports
 )
@@ -80,6 +81,9 @@ var _ = BeforeSuite(func() {
 	err = examplecomv1alpha1.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
+	err = examplecomv1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
 	// +kubebuilder:scaffold:scheme
 
 	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
diff --git a/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller.go b/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller.go
new file mode 100644
index 00000000000..3a91e610903
--- /dev/null
+++ b/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller.go
@@ -0,0 +1,63 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package examplecom
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
+)
+
+// WordpressReconciler reconciles a Wordpress object
+type WordpressReconciler struct {
+	client.Client
+	Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+// TODO(user): Modify the Reconcile function to compare the state specified by
+// the Wordpress object against the actual cluster state, and then
+// perform operations to make the cluster state reflect the state specified by
+// the user.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
+func (r *WordpressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	_ = log.FromContext(ctx)
+
+	// TODO(user): your logic here
+
+	return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *WordpressReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&examplecomv1.Wordpress{}).
+		Named("example.com-wordpress").
+		Complete(r)
+}
diff --git a/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller_test.go b/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller_test.go
new file mode 100644
index 00000000000..16278c90d4a
--- /dev/null
+++ b/testdata/project-v4-multigroup/internal/controller/example.com/wordpress_controller_test.go
@@ -0,0 +1,84 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package examplecom
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
+)
+
+var _ = Describe("Wordpress Controller", func() {
+	Context("When reconciling a resource", func() {
+		const resourceName = "test-resource"
+
+		ctx := context.Background()
+
+		typeNamespacedName := types.NamespacedName{
+			Name:      resourceName,
+			Namespace: "default", // TODO(user):Modify as needed
+		}
+		wordpress := &examplecomv1.Wordpress{}
+
+		BeforeEach(func() {
+			By("creating the custom resource for the Kind Wordpress")
+			err := k8sClient.Get(ctx, typeNamespacedName, wordpress)
+			if err != nil && errors.IsNotFound(err) {
+				resource := &examplecomv1.Wordpress{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      resourceName,
+						Namespace: "default",
+					},
+					// TODO(user): Specify other spec details if needed.
+				}
+				Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+			}
+		})
+
+		AfterEach(func() {
+			// TODO(user): Cleanup logic after each test, like removing the resource instance.
+			resource := &examplecomv1.Wordpress{}
+			err := k8sClient.Get(ctx, typeNamespacedName, resource)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("Cleanup the specific resource instance Wordpress")
+			Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+		})
+		It("should successfully reconcile the resource", func() {
+			By("Reconciling the created resource")
+			controllerReconciler := &WordpressReconciler{
+				Client: k8sClient,
+				Scheme: k8sClient.Scheme(),
+			}
+
+			_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+				NamespacedName: typeNamespacedName,
+			})
+			Expect(err).NotTo(HaveOccurred())
+			// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
+			// Example: If you expect a certain status condition after reconciliation, verify it here.
+		})
+	})
+})
diff --git a/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook.go b/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook.go
new file mode 100644
index 00000000000..9598aacdaef
--- /dev/null
+++ b/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	ctrl "sigs.k8s.io/controller-runtime"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
+)
+
+// nolint:unused
+// log is for logging in this package.
+var wordpresslog = logf.Log.WithName("wordpress-resource")
+
+// SetupWordpressWebhookWithManager registers the webhook for Wordpress in the manager.
+func SetupWordpressWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).For(&examplecomv1.Wordpress{}).
+		Complete()
+}
+
+// TODO(user): EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
diff --git a/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook_test.go b/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook_test.go
new file mode 100644
index 00000000000..d83c7847bf0
--- /dev/null
+++ b/testdata/project-v4-multigroup/internal/webhook/example.com/v1/wordpress_webhook_test.go
@@ -0,0 +1,55 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/example.com/v1"
+	// TODO (user): Add any additional imports if needed
+)
+
+var _ = Describe("Wordpress Webhook", func() {
+	var (
+		obj    *examplecomv1.Wordpress
+		oldObj *examplecomv1.Wordpress
+	)
+
+	BeforeEach(func() {
+		obj = &examplecomv1.Wordpress{}
+		oldObj = &examplecomv1.Wordpress{}
+		Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized")
+		Expect(obj).NotTo(BeNil(), "Expected obj to be initialized")
+		// TODO (user): Add any setup logic common to all tests
+	})
+
+	AfterEach(func() {
+		// TODO (user): Add any teardown logic common to all tests
+	})
+
+	Context("When creating Wordpress under Conversion Webhook", func() {
+		// TODO (user): Add logic to convert the object to the desired version and verify the conversion
+		// Example:
+		// It("Should convert the object correctly", func() {
+		//     convertedObj := &examplecomv1.Wordpress{}
+		//     Expect(obj.ConvertTo(convertedObj)).To(Succeed())
+		//     Expect(convertedObj).ToNot(BeNil())
+		// })
+	})
+
+})
diff --git a/testdata/project-v4-with-plugins/PROJECT b/testdata/project-v4-with-plugins/PROJECT
index 0e0eccc4eb2..d71a9425376 100644
--- a/testdata/project-v4-with-plugins/PROJECT
+++ b/testdata/project-v4-with-plugins/PROJECT
@@ -48,4 +48,24 @@ resources:
   kind: Busybox
   path: sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: testproject.org
+  group: example.com
+  kind: Wordpress
+  path: sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1
+  version: v1
+  webhooks:
+    conversion: true
+    webhookVersion: v1
+- api:
+    crdVersion: v1
+    namespaced: true
+  domain: testproject.org
+  group: example.com
+  kind: Wordpress
+  path: sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v2
+  version: v2
 version: "3"
diff --git a/testdata/project-v4-with-plugins/api/v1/groupversion_info.go b/testdata/project-v4-with-plugins/api/v1/groupversion_info.go
new file mode 100644
index 00000000000..f6ea9c38602
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v1/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v1 contains API Schema definitions for the example.com v1 API group.
+// +kubebuilder:object:generate=true
+// +groupName=example.com.testproject.org
+package v1
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+	// GroupVersion is group version used to register these objects.
+	GroupVersion = schema.GroupVersion{Group: "example.com.testproject.org", Version: "v1"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/testdata/project-v4-with-plugins/api/v1/wordpress_types.go b/testdata/project-v4-with-plugins/api/v1/wordpress_types.go
new file mode 100644
index 00000000000..0304ca8b4cf
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v1/wordpress_types.go
@@ -0,0 +1,65 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// WordpressSpec defines the desired state of Wordpress.
+type WordpressSpec struct {
+	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+
+	// Foo is an example field of Wordpress. Edit wordpress_types.go to remove/update
+	Foo string `json:"foo,omitempty"`
+}
+
+// WordpressStatus defines the observed state of Wordpress.
+type WordpressStatus struct {
+	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:storageversion
+// +kubebuilder:conversion:hub
+// Wordpress is the Schema for the wordpresses API.
+type Wordpress struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   WordpressSpec   `json:"spec,omitempty"`
+	Status WordpressStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// WordpressList contains a list of Wordpress.
+type WordpressList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Wordpress `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Wordpress{}, &WordpressList{})
+}
diff --git a/testdata/project-v4-with-plugins/api/v1/zz_generated.deepcopy.go b/testdata/project-v4-with-plugins/api/v1/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..8ef996e16d1
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v1/zz_generated.deepcopy.go
@@ -0,0 +1,114 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Wordpress) DeepCopyInto(out *Wordpress) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wordpress.
+func (in *Wordpress) DeepCopy() *Wordpress {
+	if in == nil {
+		return nil
+	}
+	out := new(Wordpress)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Wordpress) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressList) DeepCopyInto(out *WordpressList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Wordpress, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressList.
+func (in *WordpressList) DeepCopy() *WordpressList {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *WordpressList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressSpec) DeepCopyInto(out *WordpressSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressSpec.
+func (in *WordpressSpec) DeepCopy() *WordpressSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressStatus) DeepCopyInto(out *WordpressStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressStatus.
+func (in *WordpressStatus) DeepCopy() *WordpressStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/testdata/project-v4-with-plugins/api/v2/groupversion_info.go b/testdata/project-v4-with-plugins/api/v2/groupversion_info.go
new file mode 100644
index 00000000000..fbb9302bda5
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v2/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v2 contains API Schema definitions for the example.com v2 API group.
+// +kubebuilder:object:generate=true
+// +groupName=example.com.testproject.org
+package v2
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+	// GroupVersion is group version used to register these objects.
+	GroupVersion = schema.GroupVersion{Group: "example.com.testproject.org", Version: "v2"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/testdata/project-v4-with-plugins/api/v2/wordpress_types.go b/testdata/project-v4-with-plugins/api/v2/wordpress_types.go
new file mode 100644
index 00000000000..5a1d1ba6a68
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v2/wordpress_types.go
@@ -0,0 +1,64 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// WordpressSpec defines the desired state of Wordpress.
+type WordpressSpec struct {
+	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+
+	// Foo is an example field of Wordpress. Edit wordpress_types.go to remove/update
+	Foo string `json:"foo,omitempty"`
+}
+
+// WordpressStatus defines the observed state of Wordpress.
+type WordpressStatus struct {
+	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// Wordpress is the Schema for the wordpresses API.
+type Wordpress struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   WordpressSpec   `json:"spec,omitempty"`
+	Status WordpressStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// WordpressList contains a list of Wordpress.
+type WordpressList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Wordpress `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Wordpress{}, &WordpressList{})
+}
diff --git a/testdata/project-v4-with-plugins/api/v2/zz_generated.deepcopy.go b/testdata/project-v4-with-plugins/api/v2/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..069e5cfd811
--- /dev/null
+++ b/testdata/project-v4-with-plugins/api/v2/zz_generated.deepcopy.go
@@ -0,0 +1,114 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v2
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Wordpress) DeepCopyInto(out *Wordpress) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wordpress.
+func (in *Wordpress) DeepCopy() *Wordpress {
+	if in == nil {
+		return nil
+	}
+	out := new(Wordpress)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Wordpress) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressList) DeepCopyInto(out *WordpressList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Wordpress, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressList.
+func (in *WordpressList) DeepCopy() *WordpressList {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *WordpressList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressSpec) DeepCopyInto(out *WordpressSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressSpec.
+func (in *WordpressSpec) DeepCopy() *WordpressSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *WordpressStatus) DeepCopyInto(out *WordpressStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WordpressStatus.
+func (in *WordpressStatus) DeepCopy() *WordpressStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(WordpressStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/testdata/project-v4-with-plugins/cmd/main.go b/testdata/project-v4-with-plugins/cmd/main.go
index cf093e59d6c..11f1914fb79 100644
--- a/testdata/project-v4-with-plugins/cmd/main.go
+++ b/testdata/project-v4-with-plugins/cmd/main.go
@@ -35,8 +35,11 @@ import (
 	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
 	"sigs.k8s.io/controller-runtime/pkg/webhook"
 
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
 	examplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1alpha1"
+	examplecomv2 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v2"
 	"sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/internal/controller"
+	webhookexamplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/internal/webhook/v1"
 	webhookexamplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/internal/webhook/v1alpha1"
 	// +kubebuilder:scaffold:imports
 )
@@ -50,6 +53,8 @@ func init() {
 	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
 
 	utilruntime.Must(examplecomv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(examplecomv1.AddToScheme(scheme))
+	utilruntime.Must(examplecomv2.AddToScheme(scheme))
 	// +kubebuilder:scaffold:scheme
 }
 
@@ -166,6 +171,20 @@ func main() {
 			os.Exit(1)
 		}
 	}
+	if err = (&controller.WordpressReconciler{
+		Client: mgr.GetClient(),
+		Scheme: mgr.GetScheme(),
+	}).SetupWithManager(mgr); err != nil {
+		setupLog.Error(err, "unable to create controller", "controller", "Wordpress")
+		os.Exit(1)
+	}
+	// nolint:goconst
+	if os.Getenv("ENABLE_WEBHOOKS") != "false" {
+		if err = webhookexamplecomv1.SetupWordpressWebhookWithManager(mgr); err != nil {
+			setupLog.Error(err, "unable to create webhook", "webhook", "Wordpress")
+			os.Exit(1)
+		}
+	}
 	// +kubebuilder:scaffold:builder
 
 	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
diff --git a/testdata/project-v4-with-plugins/config/crd/bases/example.com.testproject.org_wordpresses.yaml b/testdata/project-v4-with-plugins/config/crd/bases/example.com.testproject.org_wordpresses.yaml
new file mode 100644
index 00000000000..0e059f9cc26
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/crd/bases/example.com.testproject.org_wordpresses.yaml
@@ -0,0 +1,92 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.4
+  name: wordpresses.example.com.testproject.org
+spec:
+  group: example.com.testproject.org
+  names:
+    kind: Wordpress
+    listKind: WordpressList
+    plural: wordpresses
+    singular: wordpress
+  scope: Namespaced
+  versions:
+  - name: v1
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
diff --git a/testdata/project-v4-with-plugins/config/crd/kustomization.yaml b/testdata/project-v4-with-plugins/config/crd/kustomization.yaml
index 7b2aba4eb3c..d00fe0769fc 100644
--- a/testdata/project-v4-with-plugins/config/crd/kustomization.yaml
+++ b/testdata/project-v4-with-plugins/config/crd/kustomization.yaml
@@ -4,18 +4,21 @@
 resources:
 - bases/example.com.testproject.org_memcacheds.yaml
 - bases/example.com.testproject.org_busyboxes.yaml
+- bases/example.com.testproject.org_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patches:
 # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
 # patches here are for enabling the conversion webhook for each CRD
 - path: patches/webhook_in_memcacheds.yaml
+- path: patches/webhook_in_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizewebhookpatch
 
 # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix.
 # patches here are for enabling the CA injection for each CRD
 #- path: patches/cainjection_in_memcacheds.yaml
 #- path: patches/cainjection_in_busyboxes.yaml
+#- path: patches/cainjection_in_wordpresses.yaml
 # +kubebuilder:scaffold:crdkustomizecainjectionpatch
 
 # [WEBHOOK] To enable webhook, uncomment the following section
diff --git a/testdata/project-v4-with-plugins/config/crd/patches/cainjection_in_wordpresses.yaml b/testdata/project-v4-with-plugins/config/crd/patches/cainjection_in_wordpresses.yaml
new file mode 100644
index 00000000000..94469071ccf
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/crd/patches/cainjection_in_wordpresses.yaml
@@ -0,0 +1,7 @@
+# The following patch adds a directive for certmanager to inject CA into the CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME
+  name: wordpresses.example.com.testproject.org
diff --git a/testdata/project-v4-with-plugins/config/crd/patches/webhook_in_wordpresses.yaml b/testdata/project-v4-with-plugins/config/crd/patches/webhook_in_wordpresses.yaml
new file mode 100644
index 00000000000..e335002e320
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/crd/patches/webhook_in_wordpresses.yaml
@@ -0,0 +1,16 @@
+# The following patch enables a conversion webhook for the CRD
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: wordpresses.example.com.testproject.org
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          namespace: system
+          name: webhook-service
+          path: /convert
+      conversionReviewVersions:
+      - v1
diff --git a/testdata/project-v4-with-plugins/config/rbac/kustomization.yaml b/testdata/project-v4-with-plugins/config/rbac/kustomization.yaml
index 60653a8cf59..db14c45b3f7 100644
--- a/testdata/project-v4-with-plugins/config/rbac/kustomization.yaml
+++ b/testdata/project-v4-with-plugins/config/rbac/kustomization.yaml
@@ -22,6 +22,8 @@ resources:
 # default, aiding admins in cluster management. Those roles are
 # not used by the Project itself. You can comment the following lines
 # if you do not want those helpers be installed with your Project.
+- wordpress_editor_role.yaml
+- wordpress_viewer_role.yaml
 - busybox_editor_role.yaml
 - busybox_viewer_role.yaml
 - memcached_editor_role.yaml
diff --git a/testdata/project-v4-with-plugins/config/rbac/role.yaml b/testdata/project-v4-with-plugins/config/rbac/role.yaml
index a2db0d25cca..18dc265ac24 100644
--- a/testdata/project-v4-with-plugins/config/rbac/role.yaml
+++ b/testdata/project-v4-with-plugins/config/rbac/role.yaml
@@ -36,6 +36,7 @@ rules:
   resources:
   - busyboxes
   - memcacheds
+  - wordpresses
   verbs:
   - create
   - delete
@@ -49,6 +50,7 @@ rules:
   resources:
   - busyboxes/finalizers
   - memcacheds/finalizers
+  - wordpresses/finalizers
   verbs:
   - update
 - apiGroups:
@@ -56,6 +58,7 @@ rules:
   resources:
   - busyboxes/status
   - memcacheds/status
+  - wordpresses/status
   verbs:
   - get
   - patch
diff --git a/testdata/project-v4-with-plugins/config/rbac/wordpress_editor_role.yaml b/testdata/project-v4-with-plugins/config/rbac/wordpress_editor_role.yaml
new file mode 100644
index 00000000000..8054f6a57d8
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/rbac/wordpress_editor_role.yaml
@@ -0,0 +1,27 @@
+# permissions for end users to edit wordpresses.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-with-plugins
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-editor-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
diff --git a/testdata/project-v4-with-plugins/config/rbac/wordpress_viewer_role.yaml b/testdata/project-v4-with-plugins/config/rbac/wordpress_viewer_role.yaml
new file mode 100644
index 00000000000..deca0bd8593
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/rbac/wordpress_viewer_role.yaml
@@ -0,0 +1,23 @@
+# permissions for end users to view wordpresses.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-with-plugins
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-viewer-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
diff --git a/testdata/project-v4-with-plugins/config/samples/example.com_v1_wordpress.yaml b/testdata/project-v4-with-plugins/config/samples/example.com_v1_wordpress.yaml
new file mode 100644
index 00000000000..88c91cd29b1
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/samples/example.com_v1_wordpress.yaml
@@ -0,0 +1,9 @@
+apiVersion: example.com.testproject.org/v1
+kind: Wordpress
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-with-plugins
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-sample
+spec:
+  # TODO(user): Add fields here
diff --git a/testdata/project-v4-with-plugins/config/samples/example.com_v2_wordpress.yaml b/testdata/project-v4-with-plugins/config/samples/example.com_v2_wordpress.yaml
new file mode 100644
index 00000000000..24bdf117e64
--- /dev/null
+++ b/testdata/project-v4-with-plugins/config/samples/example.com_v2_wordpress.yaml
@@ -0,0 +1,9 @@
+apiVersion: example.com.testproject.org/v2
+kind: Wordpress
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4-with-plugins
+    app.kubernetes.io/managed-by: kustomize
+  name: wordpress-sample
+spec:
+  # TODO(user): Add fields here
diff --git a/testdata/project-v4-with-plugins/config/samples/kustomization.yaml b/testdata/project-v4-with-plugins/config/samples/kustomization.yaml
index 44b0f44adcb..86922c235b3 100644
--- a/testdata/project-v4-with-plugins/config/samples/kustomization.yaml
+++ b/testdata/project-v4-with-plugins/config/samples/kustomization.yaml
@@ -2,4 +2,6 @@
 resources:
 - example.com_v1alpha1_memcached.yaml
 - example.com_v1alpha1_busybox.yaml
+- example.com_v1_wordpress.yaml
+- example.com_v2_wordpress.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples
diff --git a/testdata/project-v4-with-plugins/dist/install.yaml b/testdata/project-v4-with-plugins/dist/install.yaml
index 66788aef372..875bfef3a06 100644
--- a/testdata/project-v4-with-plugins/dist/install.yaml
+++ b/testdata/project-v4-with-plugins/dist/install.yaml
@@ -254,6 +254,108 @@ spec:
     subresources:
       status: {}
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.4
+  name: wordpresses.example.com.testproject.org
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          name: project-v4-with-plugins-webhook-service
+          namespace: project-v4-with-plugins-system
+          path: /convert
+      conversionReviewVersions:
+      - v1
+  group: example.com.testproject.org
+  names:
+    kind: Wordpress
+    listKind: WordpressList
+    plural: wordpresses
+    singular: wordpress
+  scope: Namespaced
+  versions:
+  - name: v1
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: Wordpress is the Schema for the wordpresses API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: WordpressSpec defines the desired state of Wordpress.
+            properties:
+              foo:
+                description: Foo is an example field of Wordpress. Edit wordpress_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: WordpressStatus defines the observed state of Wordpress.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+---
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -391,6 +493,7 @@ rules:
   resources:
   - busyboxes
   - memcacheds
+  - wordpresses
   verbs:
   - create
   - delete
@@ -404,6 +507,7 @@ rules:
   resources:
   - busyboxes/finalizers
   - memcacheds/finalizers
+  - wordpresses/finalizers
   verbs:
   - update
 - apiGroups:
@@ -411,6 +515,7 @@ rules:
   resources:
   - busyboxes/status
   - memcacheds/status
+  - wordpresses/status
   verbs:
   - get
   - patch
@@ -495,6 +600,56 @@ rules:
   - get
 ---
 apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/managed-by: kustomize
+    app.kubernetes.io/name: project-v4-with-plugins
+  name: project-v4-with-plugins-wordpress-editor-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app.kubernetes.io/managed-by: kustomize
+    app.kubernetes.io/name: project-v4-with-plugins
+  name: project-v4-with-plugins-wordpress-viewer-role
+rules:
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - example.com.testproject.org
+  resources:
+  - wordpresses/status
+  verbs:
+  - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
 kind: RoleBinding
 metadata:
   labels:
diff --git a/testdata/project-v4-with-plugins/internal/controller/suite_test.go b/testdata/project-v4-with-plugins/internal/controller/suite_test.go
index 5259421161e..0114fe3b039 100644
--- a/testdata/project-v4-with-plugins/internal/controller/suite_test.go
+++ b/testdata/project-v4-with-plugins/internal/controller/suite_test.go
@@ -33,6 +33,7 @@ import (
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
 	examplecomv1alpha1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1alpha1"
 	// +kubebuilder:scaffold:imports
 )
@@ -80,6 +81,9 @@ var _ = BeforeSuite(func() {
 	err = examplecomv1alpha1.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
+	err = examplecomv1.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
 	// +kubebuilder:scaffold:scheme
 
 	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
diff --git a/testdata/project-v4-with-plugins/internal/controller/wordpress_controller.go b/testdata/project-v4-with-plugins/internal/controller/wordpress_controller.go
new file mode 100644
index 00000000000..814224c6a11
--- /dev/null
+++ b/testdata/project-v4-with-plugins/internal/controller/wordpress_controller.go
@@ -0,0 +1,63 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
+)
+
+// WordpressReconciler reconciles a Wordpress object
+type WordpressReconciler struct {
+	client.Client
+	Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=example.com.testproject.org,resources=wordpresses/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+// TODO(user): Modify the Reconcile function to compare the state specified by
+// the Wordpress object against the actual cluster state, and then
+// perform operations to make the cluster state reflect the state specified by
+// the user.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/reconcile
+func (r *WordpressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	_ = log.FromContext(ctx)
+
+	// TODO(user): your logic here
+
+	return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *WordpressReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&examplecomv1.Wordpress{}).
+		Named("wordpress").
+		Complete(r)
+}
diff --git a/testdata/project-v4-with-plugins/internal/controller/wordpress_controller_test.go b/testdata/project-v4-with-plugins/internal/controller/wordpress_controller_test.go
new file mode 100644
index 00000000000..d25dcb12a20
--- /dev/null
+++ b/testdata/project-v4-with-plugins/internal/controller/wordpress_controller_test.go
@@ -0,0 +1,84 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
+)
+
+var _ = Describe("Wordpress Controller", func() {
+	Context("When reconciling a resource", func() {
+		const resourceName = "test-resource"
+
+		ctx := context.Background()
+
+		typeNamespacedName := types.NamespacedName{
+			Name:      resourceName,
+			Namespace: "default", // TODO(user):Modify as needed
+		}
+		wordpress := &examplecomv1.Wordpress{}
+
+		BeforeEach(func() {
+			By("creating the custom resource for the Kind Wordpress")
+			err := k8sClient.Get(ctx, typeNamespacedName, wordpress)
+			if err != nil && errors.IsNotFound(err) {
+				resource := &examplecomv1.Wordpress{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      resourceName,
+						Namespace: "default",
+					},
+					// TODO(user): Specify other spec details if needed.
+				}
+				Expect(k8sClient.Create(ctx, resource)).To(Succeed())
+			}
+		})
+
+		AfterEach(func() {
+			// TODO(user): Cleanup logic after each test, like removing the resource instance.
+			resource := &examplecomv1.Wordpress{}
+			err := k8sClient.Get(ctx, typeNamespacedName, resource)
+			Expect(err).NotTo(HaveOccurred())
+
+			By("Cleanup the specific resource instance Wordpress")
+			Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
+		})
+		It("should successfully reconcile the resource", func() {
+			By("Reconciling the created resource")
+			controllerReconciler := &WordpressReconciler{
+				Client: k8sClient,
+				Scheme: k8sClient.Scheme(),
+			}
+
+			_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
+				NamespacedName: typeNamespacedName,
+			})
+			Expect(err).NotTo(HaveOccurred())
+			// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
+			// Example: If you expect a certain status condition after reconciliation, verify it here.
+		})
+	})
+})
diff --git a/testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook.go b/testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook.go
similarity index 68%
rename from testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook.go
rename to testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook.go
index 90b5342fa05..5361361dc48 100644
--- a/testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook.go
+++ b/testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook.go
@@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package v1beta1
+package v1
 
 import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
 
-	shipv1beta1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/ship/v1beta1"
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
 )
 
 // nolint:unused
 // log is for logging in this package.
-var frigatelog = logf.Log.WithName("frigate-resource")
+var wordpresslog = logf.Log.WithName("wordpress-resource")
 
-// SetupFrigateWebhookWithManager registers the webhook for Frigate in the manager.
-func SetupFrigateWebhookWithManager(mgr ctrl.Manager) error {
-	return ctrl.NewWebhookManagedBy(mgr).For(&shipv1beta1.Frigate{}).
+// SetupWordpressWebhookWithManager registers the webhook for Wordpress in the manager.
+func SetupWordpressWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).For(&examplecomv1.Wordpress{}).
 		Complete()
 }
 
diff --git a/testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook_test.go b/testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook_test.go
similarity index 76%
rename from testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook_test.go
rename to testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook_test.go
index 1882c0df82c..4d22379e74e 100644
--- a/testdata/project-v4-multigroup/internal/webhook/ship/v1beta1/frigate_webhook_test.go
+++ b/testdata/project-v4-with-plugins/internal/webhook/v1/wordpress_webhook_test.go
@@ -14,25 +14,25 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-package v1beta1
+package v1
 
 import (
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 
-	shipv1beta1 "sigs.k8s.io/kubebuilder/testdata/project-v4-multigroup/api/ship/v1beta1"
+	examplecomv1 "sigs.k8s.io/kubebuilder/testdata/project-v4-with-plugins/api/v1"
 	// TODO (user): Add any additional imports if needed
 )
 
-var _ = Describe("Frigate Webhook", func() {
+var _ = Describe("Wordpress Webhook", func() {
 	var (
-		obj    *shipv1beta1.Frigate
-		oldObj *shipv1beta1.Frigate
+		obj    *examplecomv1.Wordpress
+		oldObj *examplecomv1.Wordpress
 	)
 
 	BeforeEach(func() {
-		obj = &shipv1beta1.Frigate{}
-		oldObj = &shipv1beta1.Frigate{}
+		obj = &examplecomv1.Wordpress{}
+		oldObj = &examplecomv1.Wordpress{}
 		Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized")
 		Expect(obj).NotTo(BeNil(), "Expected obj to be initialized")
 		// TODO (user): Add any setup logic common to all tests
@@ -42,11 +42,11 @@ var _ = Describe("Frigate Webhook", func() {
 		// TODO (user): Add any teardown logic common to all tests
 	})
 
-	Context("When creating Frigate under Conversion Webhook", func() {
+	Context("When creating Wordpress under Conversion Webhook", func() {
 		// TODO (user): Add logic to convert the object to the desired version and verify the conversion
 		// Example:
 		// It("Should convert the object correctly", func() {
-		//     convertedObj := &shipv1beta1.Frigate{}
+		//     convertedObj := &examplecomv1.Wordpress{}
 		//     Expect(obj.ConvertTo(convertedObj)).To(Succeed())
 		//     Expect(convertedObj).ToNot(BeNil())
 		// })
diff --git a/testdata/project-v4/PROJECT b/testdata/project-v4/PROJECT
index 1ea7da269fe..de92e3145d7 100644
--- a/testdata/project-v4/PROJECT
+++ b/testdata/project-v4/PROJECT
@@ -33,6 +33,14 @@ resources:
   webhooks:
     conversion: true
     webhookVersion: v1
+- api:
+    crdVersion: v1
+    namespaced: true
+  domain: testproject.org
+  group: crew
+  kind: FirstMate
+  path: sigs.k8s.io/kubebuilder/testdata/project-v4/api/v2
+  version: v2
 - api:
     crdVersion: v1
   controller: true
diff --git a/testdata/project-v4/api/v1/firstmate_types.go b/testdata/project-v4/api/v1/firstmate_types.go
index 63ce2a69d17..f344fe67bac 100644
--- a/testdata/project-v4/api/v1/firstmate_types.go
+++ b/testdata/project-v4/api/v1/firstmate_types.go
@@ -40,7 +40,8 @@ type FirstMateStatus struct {
 
 // +kubebuilder:object:root=true
 // +kubebuilder:subresource:status
-
+// +kubebuilder:storageversion
+// +kubebuilder:conversion:hub
 // FirstMate is the Schema for the firstmates API.
 type FirstMate struct {
 	metav1.TypeMeta   `json:",inline"`
diff --git a/testdata/project-v4/api/v2/firstmate_types.go b/testdata/project-v4/api/v2/firstmate_types.go
new file mode 100644
index 00000000000..2f6f0ebd84c
--- /dev/null
+++ b/testdata/project-v4/api/v2/firstmate_types.go
@@ -0,0 +1,64 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
+
+// FirstMateSpec defines the desired state of FirstMate.
+type FirstMateSpec struct {
+	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+
+	// Foo is an example field of FirstMate. Edit firstmate_types.go to remove/update
+	Foo string `json:"foo,omitempty"`
+}
+
+// FirstMateStatus defines the observed state of FirstMate.
+type FirstMateStatus struct {
+	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
+	// Important: Run "make" to regenerate code after modifying this file
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// FirstMate is the Schema for the firstmates API.
+type FirstMate struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   FirstMateSpec   `json:"spec,omitempty"`
+	Status FirstMateStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// FirstMateList contains a list of FirstMate.
+type FirstMateList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []FirstMate `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&FirstMate{}, &FirstMateList{})
+}
diff --git a/testdata/project-v4/api/v2/groupversion_info.go b/testdata/project-v4/api/v2/groupversion_info.go
new file mode 100644
index 00000000000..2751fd346b1
--- /dev/null
+++ b/testdata/project-v4/api/v2/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package v2 contains API Schema definitions for the crew v2 API group.
+// +kubebuilder:object:generate=true
+// +groupName=crew.testproject.org
+package v2
+
+import (
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+	// GroupVersion is group version used to register these objects.
+	GroupVersion = schema.GroupVersion{Group: "crew.testproject.org", Version: "v2"}
+
+	// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+	SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+	// AddToScheme adds the types in this group-version to the given scheme.
+	AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/testdata/project-v4/api/v2/zz_generated.deepcopy.go b/testdata/project-v4/api/v2/zz_generated.deepcopy.go
new file mode 100644
index 00000000000..db8e6aded83
--- /dev/null
+++ b/testdata/project-v4/api/v2/zz_generated.deepcopy.go
@@ -0,0 +1,114 @@
+//go:build !ignore_autogenerated
+
+/*
+Copyright 2024 The Kubernetes authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v2
+
+import (
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirstMate) DeepCopyInto(out *FirstMate) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMate.
+func (in *FirstMate) DeepCopy() *FirstMate {
+	if in == nil {
+		return nil
+	}
+	out := new(FirstMate)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FirstMate) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirstMateList) DeepCopyInto(out *FirstMateList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]FirstMate, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateList.
+func (in *FirstMateList) DeepCopy() *FirstMateList {
+	if in == nil {
+		return nil
+	}
+	out := new(FirstMateList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FirstMateList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirstMateSpec) DeepCopyInto(out *FirstMateSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateSpec.
+func (in *FirstMateSpec) DeepCopy() *FirstMateSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(FirstMateSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FirstMateStatus) DeepCopyInto(out *FirstMateStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateStatus.
+func (in *FirstMateStatus) DeepCopy() *FirstMateStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(FirstMateStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/testdata/project-v4/cmd/main.go b/testdata/project-v4/cmd/main.go
index 6d3a70630b8..e7b186138dd 100644
--- a/testdata/project-v4/cmd/main.go
+++ b/testdata/project-v4/cmd/main.go
@@ -38,6 +38,7 @@ import (
 	certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
 
 	crewv1 "sigs.k8s.io/kubebuilder/testdata/project-v4/api/v1"
+	crewv2 "sigs.k8s.io/kubebuilder/testdata/project-v4/api/v2"
 	"sigs.k8s.io/kubebuilder/testdata/project-v4/internal/controller"
 	webhookcertmanagerv1 "sigs.k8s.io/kubebuilder/testdata/project-v4/internal/webhook/v1"
 	webhookcorev1 "sigs.k8s.io/kubebuilder/testdata/project-v4/internal/webhook/v1"
@@ -54,6 +55,7 @@ func init() {
 	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
 
 	utilruntime.Must(crewv1.AddToScheme(scheme))
+	utilruntime.Must(crewv2.AddToScheme(scheme))
 	utilruntime.Must(certmanagerv1.AddToScheme(scheme))
 	// +kubebuilder:scaffold:scheme
 }
diff --git a/testdata/project-v4/config/crd/bases/crew.testproject.org_firstmates.yaml b/testdata/project-v4/config/crd/bases/crew.testproject.org_firstmates.yaml
index 550eb68d8e6..b597e31a390 100644
--- a/testdata/project-v4/config/crd/bases/crew.testproject.org_firstmates.yaml
+++ b/testdata/project-v4/config/crd/bases/crew.testproject.org_firstmates.yaml
@@ -52,3 +52,41 @@ spec:
     storage: true
     subresources:
       status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: FirstMate is the Schema for the firstmates API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: FirstMateSpec defines the desired state of FirstMate.
+            properties:
+              foo:
+                description: Foo is an example field of FirstMate. Edit firstmate_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: FirstMateStatus defines the observed state of FirstMate.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
diff --git a/testdata/project-v4/config/samples/crew_v2_firstmate.yaml b/testdata/project-v4/config/samples/crew_v2_firstmate.yaml
new file mode 100644
index 00000000000..725f8761f4a
--- /dev/null
+++ b/testdata/project-v4/config/samples/crew_v2_firstmate.yaml
@@ -0,0 +1,9 @@
+apiVersion: crew.testproject.org/v2
+kind: FirstMate
+metadata:
+  labels:
+    app.kubernetes.io/name: project-v4
+    app.kubernetes.io/managed-by: kustomize
+  name: firstmate-sample
+spec:
+  # TODO(user): Add fields here
diff --git a/testdata/project-v4/config/samples/kustomization.yaml b/testdata/project-v4/config/samples/kustomization.yaml
index 787262813fe..ce83fd55d0c 100644
--- a/testdata/project-v4/config/samples/kustomization.yaml
+++ b/testdata/project-v4/config/samples/kustomization.yaml
@@ -2,5 +2,6 @@
 resources:
 - crew_v1_captain.yaml
 - crew_v1_firstmate.yaml
+- crew_v2_firstmate.yaml
 - crew_v1_admiral.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples
diff --git a/testdata/project-v4/dist/install.yaml b/testdata/project-v4/dist/install.yaml
index 644e7898cf7..5290b3bd872 100644
--- a/testdata/project-v4/dist/install.yaml
+++ b/testdata/project-v4/dist/install.yaml
@@ -198,6 +198,44 @@ spec:
     storage: true
     subresources:
       status: {}
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: FirstMate is the Schema for the firstmates API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: FirstMateSpec defines the desired state of FirstMate.
+            properties:
+              foo:
+                description: Foo is an example field of FirstMate. Edit firstmate_types.go
+                  to remove/update
+                type: string
+            type: object
+          status:
+            description: FirstMateStatus defines the observed state of FirstMate.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
 ---
 apiVersion: v1
 kind: ServiceAccount