Repository: operator-framework/operator-sdk
Pull request #4552 (2021-02-18 20:38:15)

test/: refactor scorecard e2e test, clean up existing test poll funcs
**Description of the change:**
- test/: refactor scorecard tests into a spec function in common, remove 'By()' calls in poll function

**Motivation for the change:** scorecard test code is repeated across all e2e test types. This PR refactors that code into a spec-running function.

/area testing

**Checklist**

If the pull request includes user-facing changes, extra documentation is required:
- [ ] Add a new changelog fragment in `changelog/fragments` (see [`changelog/fragments/00-template.yaml`](https://github.com/operator-framework/operator-sdk/tree/master/changelog/fragments/00-template.yaml))
- [ ] Add or update relevant sections of the docs website in [`website/content/en/docs`](https://github.com/operator-framework/operator-sdk/tree/master/website/content/en/docs)



Patch:
diff --git a/.ci/gpg/create-keyring.sh b/.ci/gpg/create-keyring.sh
index a7f1dce9..7837e313 100755
--- a/.ci/gpg/create-keyring.sh
+++ b/.ci/gpg/create-keyring.sh
@@ -18,7 +18,7 @@ cp "${DIR}"/*.auto* "${GPG_HOME}"
 
 echo -e "\nDecrypting secret key..."
 {
-  # $GPG_PASSWORD is taken from the script's env (injected by Travis CI).
+  # $GPG_PASSWORD is taken from the script's env (injected by CI).
   echo $GPG_PASSWORD | gpg --decrypt \
     --pinentry-mode loopback --batch \
     --passphrase-fd 0 \
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..b38b8284
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,99 @@
+name: deploy
+
+on:
+  push:
+    branches:
+    - '**'
+    tags:
+    - 'v*'
+  pull_request:
+    branches: [ master ]
+
+jobs:
+  # Job to test release steps. This will only create a release remotely if run on a tagged commit.
+  goreleaser:
+    name: goreleaser
+    runs-on: ubuntu-18.04
+    environment: deploy
+    steps:
+    - name: checkout
+      uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+
+    - name: install
+      uses: actions/setup-go@v2
+      with:
+        go-version: 1.15.5
+
+    - name: gpg init
+      if: github.event_name != 'pull_request'
+      run: .ci/gpg/create-keyring.sh
+      env:
+        GPG_PASSWORD: ${{ secrets.GPG_PASSWORD }}
+
+    - name: release
+      run: |
+        if [[ $GITHUB_REF != refs/tags/* ]]; then
+          export DRY_RUN=1
+        fi
+        make release
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  # Job matrix for image builds.
+  images:
+    name: images
+    runs-on: ubuntu-18.04
+    environment: deploy
+    strategy:
+      matrix:
+        id: ["operator-sdk", "ansible-operator", "helm-operator", "scorecard-test", "scorecard-test-kuttl"]
+    steps:
+
+    - name: set up qemu
+      uses: docker/setup-qemu-action@v1
+
+    - name: set up buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: quay.io login
+      if: github.event_name != 'pull_request'
+      uses: docker/login-action@v1
+      with:
+        username: ${{ secrets.QUAY_USERNAME }}
+        password: ${{ secrets.QUAY_PASSWORD }}
+        registry: quay.io
+
+    - name: create tags
+      id: tags
+      run: |
+        IMG=quay.io/operator-framework/${{ matrix.id }}
+        if [[ $GITHUB_REF == refs/tags/* ]]; then
+          TAG=${GITHUB_REF#refs/tags/}
+          MAJOR_MINOR=${TAG%.*}
+          echo ::set-output name=tags::${IMG}:${TAG},${IMG}:${MAJOR_MINOR}
+
+        elif [[ $GITHUB_REF == refs/heads/* ]]; then
+          TAG=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g')
+          echo ::set-output name=tags::${IMG}:${TAG}
+
+        elif [[ $GITHUB_REF == refs/pull/* ]]; then
+          TAG=pr-${{ github.event.number }}
+          echo ::set-output name=tags::${IMG}:${TAG}
+        fi
+
+    - name: checkout
+      uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+
+    - name: build and push
+      uses: docker/build-push-action@v2
+      with:
+        file: ./images/${{ matrix.id }}/Dockerfile
+        context: .
+        # s390x is not supported by the scorecard-test-kuttl base image.
+        platforms: linux/amd64,linux/arm64,linux/ppc64le${{ matrix.id != 'scorecard-test-kuttl' && ',linux/s390x' || '' }}
+        push: ${{ (github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/') || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) )) }}
+        tags: ${{ steps.tags.outputs.tags }}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5d2658e4..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,166 +0,0 @@
-os: linux
-dist: xenial
-
-language: go
-go:
-- 1.15.5
-go_import_path: github.com/operator-framework/operator-sdk
-
-cache:
-  directories:
-    - ${HOME}/.cache/go-build
-    - $(go env GOPATH)/pkg/mod
-
-# The `x_base_steps` top-level key is unknown to travis,
-# so we can use it to create a bunch of common build step
-# YAML anchors which we use in our build jobs.
-x_base_steps:
-  # Base go, ansbile, and helm job
-  - &test
-    before_install:
-      # hack/ci/check-doc-only-update.sh needs to be sourced so
-      # that it can properly exit the test early with success
-      - source hack/ci/check-doc-only-update.sh "$TRAVIS_COMMIT_RANGE"
-      - git fetch origin --unshallow --tags
-    after_failure:
-      - kubectl get all --all-namespaces
-      - kubectl get events --all-namespaces --field-selector=type=Warning
-    services:
-      - docker
-
-  # Base deploy job
-  - &deploy
-    before_install:
-      - git fetch origin --unshallow --tags
-    services:
-      - docker
-
-  # Manifest list deploy job
-  - &manifest-deploy
-    before_script:
-      - git fetch origin --unshallow --tags
-      # TODO: remove the chmod command when docker-ce on travis is upgraded to 18.09.0+.
-      # We need /etc/docker to be accessible to non-root users.
-      # See https://github.com/moby/moby/pull/37847.
-      - sudo chmod 0755 /etc/docker
-    services:
-      - docker
-
-stages:
-  - name: check
-    if: type == pull_request
-  - name: test
-    if: type == pull_request
-  - name: deploy
-    if: type != pull_request AND ( tag IS present OR branch = master OR commit_message =~ /\[travis deploy\]/ )
-  - name: deploy-manifest-multiarch
-    if: type != pull_request AND ( tag IS present OR branch = master OR commit_message =~ /\[travis deploy\]/ )
-  - name: release
-    if: type != pull_request AND tag IS present
-
-jobs:
-  include:
-
-    ## Project check stage jobs ##
-
-    # Run the sanity tests
-    - stage: check
-      name: sanity
-      before_install:
-        - git fetch origin --unshallow --tags
-      script:
-        - make test-sanity
-
-    # Run website checks
-    - name: docs
-      script:
-        - make test-docs
-
-    ## Operator test stage jobs ##
-
-    # Build and test ansible and test ansible using molecule
-    - stage: test
-      name: ansible e2e
-      <<: *test
-      env:
-        # Required to set python3 as the default python interpreter.
-        - PATH=/opt/python/3.6.7/bin:${PATH}
-      before_script:
-        - sudo apt-get install python3 python3-pip
-        - pip3 install --upgrade setuptools pip
-        - pip install --user ansible~=2.9.13
-      script:
-        - make test-e2e-ansible test-e2e-ansible-molecule
-
-    # Test subcommands
-    - name: subcommand and integration
-      <<: *test
-      script:
-      - make test-e2e-integration
-
-    # Build and test go
-    - name: go unit and e2e
-      <<: *test
-      before_script:
-      - (cd / && go get github.com/mattn/goveralls)
-      script:
-      - make test-unit
-      - make test-e2e-go
-      after_success:
-      - $GOPATH/bin/goveralls -service=travis-ci -coverprofile=coverage.out -repotoken=$COVERALLS_TOKEN
-
-    # Build and test helm
-    - name: helm e2e
-      <<: *test
-      script: make test-e2e-helm
-
-    ## Image deploy/push stage jobs ##
-
-    # Build and deploy arm64 docker images
-    - stage: deploy
-      name: build and push images
-      arch: arm64
-      <<: *deploy
-      script:
-        - make image-build
-        - make -f release/Makefile image-push
-
-    # Build and deploy amd64 docker images
-    - name: build and push images
-      arch: amd64
-      <<: *deploy
-      script:
-        - make image-build
-        - make -f release/Makefile image-push
-
-    # Build and deploy ppc64le docker images
-    - name: build and push images
-      arch: ppc64le
-      <<: *deploy
-      script:
-        - make image-build
-        - make -f release/Makefile image-push
-
-    # Build and deploy s390x docker images
-    - name: build and push images
-      arch: s390x
-      <<: *deploy
-      script:
-        # Use targets directly since kuttl doesn't support s390x.
-        - make image/ansible-operator image/helm-operator image/operator-sdk image/scorecard-test
-        - make -f release/Makefile image-push/ansible-operator image-push/helm-operator image-push/operator-sdk image-push/scorecard-test
-
-    # Build and deploy ansible multi-arch manifest list
-    - stage: deploy-manifest-multiarch
-      name: push manifest lists
-      <<: *manifest-deploy
-      script: make -f release/Makefile image-push-multiarch
-
-    ## Release jobs ##
-
-    - stage: release
-      name: publish release
-      before_install: git fetch origin --unshallow --tags
-      install: sudo ln -sf $(command -v gpg2) $(dirname $(command -v gpg2))/gpg
-      before_script: .ci/gpg/create-keyring.sh
-      script: make release
diff --git a/Makefile b/Makefile
index ddc84a5e..41d824c3 100644
--- a/Makefile
+++ b/Makefile
@@ -66,13 +66,14 @@ build: ## Build operator-sdk, ansible-operator, and helm-operator.
 	@mkdir -p $(BUILD_DIR)
 	go build $(GO_BUILD_ARGS) -o $(BUILD_DIR) ./cmd/{operator-sdk,ansible-operator,helm-operator}
 
+.PHONY: build/operator-sdk build/ansible-operator build/helm-operator
+build/operator-sdk build/ansible-operator build/helm-operator:
+	go build $(GO_BUILD_ARGS) -o $(BUILD_DIR)/$(@F) ./cmd/$(@F)
+
 # Build scorecard binaries.
 .PHONY: build/scorecard-test build/scorecard-test-kuttl build/custom-scorecard-tests
 build/scorecard-test build/scorecard-test-kuttl build/custom-scorecard-tests:
 	go build $(GO_GCFLAGS) $(GO_ASMFLAGS) -o $(BUILD_DIR)/$(@F) ./images/$(@F)
-.PHONY: build/operator-sdk build/ansible-operator build/helm-operator
-build/operator-sdk build/ansible-operator build/helm-operator:
-	go build $(GO_BUILD_ARGS) -o $(BUILD_DIR)/$(@F) ./cmd/$(@F)
 
 ##@ Dev image build
 
@@ -83,13 +84,13 @@ image-build: $(foreach i,$(IMAGE_TARGET_LIST),image/$(i)) ## Build all images.
 
 # Build an image.
 BUILD_IMAGE_REPO = quay.io/operator-framework
-image/%: BUILD_DIR = build/_image
-# Images run on the linux kernel, so binaries must always target linux.
-image/%: export GOOS = linux
-image/%: build/%
-	mkdir -p ./images/$*/bin && mv $(BUILD_DIR)/$* ./images/$*/bin
-	docker build -t $(BUILD_IMAGE_REPO)/$*:dev -f ./images/$*/Dockerfile ./images/$*
-	rm -rf $(BUILD_DIR)
+# When running in a terminal, this will be false. If true (ex. CI), print plain progress.
+ifneq ($(shell test -t 0; echo $$?),0)
+DOCKER_PROGRESS = --progress plain
+endif
+image/%: export DOCKER_CLI_EXPERIMENTAL = enabled
+image/%:
+	docker buildx build $(DOCKER_PROGRESS) -t $(BUILD_IMAGE_REPO)/$*:dev -f ./images/$*/Dockerfile --load .
 
 ##@ Release
 
diff --git a/README.md b/README.md
index 13f6aeb1..4f191b45 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,9 @@
 <img src="website/static/operator_logo_sdk_color.svg" height="125px"></img>
 
-[![Build Status](https://travis-ci.com/operator-framework/operator-sdk.svg?branch=master)](https://travis-ci.com/operator-framework/operator-sdk)
+
+<!-- TODO(estroz): uncomment this when .github.com/workflows/deploy.yml is merged
+[![Build Status](https://github.com/operator-framework/operator-sdk/workflows/deploy/badge.svg)](https://github.com/operator-framework/operator-sdk/actions)
+-->
 [![License](http://img.shields.io/:license-apache-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0.html)
 [![Go Report Card](https://goreportcard.com/badge/github.com/operator-framework/operator-sdk)](https://goreportcard.com/report/github.com/operator-framework/operator-sdk)
 
@@ -16,7 +19,7 @@ Operators, in an effective, automated, and scalable way. Read more in
 the [introduction blog post][of-blog].
 
 [Operators][operator-link] make it easy to manage complex stateful
-applications on top of Kubernetes. However writing an operator today can
+applications on top of Kubernetes. However writing an Operator today can
 be difficult because of challenges such as using low level APIs, writing
 boilerplate, and a lack of modularity which leads to duplication.
 
@@ -26,40 +29,31 @@ operators easier by providing:
 
 - High level APIs and abstractions to write the operational logic more intuitively
 - Tools for scaffolding and code generation to bootstrap a new project fast
-- Extensions to cover common operator use cases
+- Extensions to cover common Operator use cases
 
 ## Dependency and platform support
 
 ### Go version
 
-Release binaries will be built with the Go compiler version specified in the Operator SDK's [prerequisites section][doc-readme-prereqs].
-
-### Kubernetes versions
+Release binaries will be built with the Go compiler version specified in the [developer guide][dev-guide-prereqs].
+A Go Operator project's Go version can be found in its `go.mod` file.
 
-As the Operator SDK interacts directly with the Kubernetes API, certain API features are assumed to exist in the target cluster.
-The currently supported Kubernetes version will always be listed in the SDK [prerequisites section][doc-readme-prereqs].
+[dev-guide-prereqs]:https://sdk.operatorframework.io/docs/contribution-guidelines/developer-guide#prerequisites
 
-### Platforms
+### Kubernetes versions
 
-The following matrix defines which architectures are supported for GNU Linux:
+Supported Kubernetes versions for your Operator project or relevant binary can be determined
+by following this [compatibility guide][k8s-compat].
 
-|                               |     `amd64`     |     `arm64`     |    `ppc64le`    |     `s390x`     |
-|-------------------------------|-----------------|-----------------|-----------------|-----------------|
-| `operator-sdk`                | ✓               | ✓               | ✓               | ✓               |
-| `ansible-operator`            | ✓               | ✓               | ✓               | ✓               |
-| `helm-operator`               | ✓               | ✓               | ✓               | ✓               |
-| `scorecard-test` image        | ✓               | ✓               | ✓               | ✓               |
-| `scorecard-test-kuttl` image  | ✓               | ✓               | ✓               | -               |
+<!-- TODO(estroz): remove the master subdomain once the updated doc is released (v1.5.0) -->
+[k8s-compat]:https://master.sdk.operatorframework.io/docs/overview#kubernetes-version-compatibility
 
-The following matrix defines which architectures are supported for MacOS Darwin:
+### Platforms
 
-|                               |     `amd64`     |
-|-------------------------------|-----------------|
-| `operator-sdk`                | ✓               |
-| `ansible-operator`            | ✓               |
-| `helm-operator`               | ✓               |
+The set of supported platforms for all binaries and images can be found in [these tables][platforms].
 
-Support for the Windows platform is not on the roadmap at this time.
+<!-- TODO(estroz): remove the master subdomain once the updated doc is released (v1.5.0) -->
+[platforms]:https://master.sdk.operatorframework.io/docs/overview#platform-support
 
 ## Community and how to get involved
 
@@ -78,7 +72,7 @@ Operator SDK is under Apache 2.0 license. See the [LICENSE][license_file] file f
 [controller-runtime]: https://github.com/kubernetes-sigs/controller-runtime
 [license_file]:./LICENSE
 [of-home]: https://github.com/operator-framework
-[of-blog]: https://coreos.com/blog/introducing-operator-framework
+[of-blog]: https://www.openshift.com/blog/introducing-the-operator-framework
 [operator-link]: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
 [sdk-docs]: https://sdk.operatorframework.io
 [operator-framework-community]: https://github.com/operator-framework/community
diff --git a/changelog/fragments/ansible-graceful-shutdown-timeout.yaml b/changelog/fragments/ansible-graceful-shutdown-timeout.yaml
new file mode 100644
index 00000000..b2f1679d
--- /dev/null
+++ b/changelog/fragments/ansible-graceful-shutdown-timeout.yaml
@@ -0,0 +1,5 @@
+entries:
+  - description: >
+      For Ansible-based operators, allow passing a --graceful-shutdown-timeout to configure the duration the manager should wait before stopping.
+    kind: "addition"
+    breaking: false
diff --git a/changelog/fragments/bugfix-4575.yaml b/changelog/fragments/bugfix-4575.yaml
new file mode 100644
index 00000000..119e6f1f
--- /dev/null
+++ b/changelog/fragments/bugfix-4575.yaml
@@ -0,0 +1,5 @@
+entries:
+  - description: >
+      Fixed samples kustomization.yaml generation on `operator-sdk init --plugins=helm --helm-chart=<chart>`,
+      caused by out-of-order operations in plugin code.
+    kind: bugfix
diff --git a/changelog/fragments/fix-generate-bundle-flags.yaml b/changelog/fragments/fix-generate-bundle-flags.yaml
new file mode 100644
index 00000000..a962c0df
--- /dev/null
+++ b/changelog/fragments/fix-generate-bundle-flags.yaml
@@ -0,0 +1,7 @@
+entries:
+  - description: >
+      Fixed ambiguous `--input-dir` and `--deploy-dir` flag semantics for `generate bundle`
+    kind: bugfix
+  - description: >
+      Improved `generate bundle` and `generate packagemanifests` handling and help text for use outside of a project
+    kind: change
diff --git a/hack/ci/check-doc-only-update.sh b/hack/ci/check-doc-only-update.sh
index 5bc6b9ad..a3788004 100755
--- a/hack/ci/check-doc-only-update.sh
+++ b/hack/ci/check-doc-only-update.sh
@@ -3,7 +3,6 @@
 set -e
 
 # If running in Github actions: this should be set to "github.base_ref".
-# If running in Travis CI: this should be set to "$TRAVIS_COMMIT_RANGE".
 : ${1?"the first argument must be set to a commit-ish reference"}
 
 # Patterns to ignore.
diff --git a/hack/generate/samples/internal/go/v2/memcached_with_webhooks.go b/hack/generate/samples/internal/go/v2/memcached_with_webhooks.go
index 958b498a..2c5415b0 100644
--- a/hack/generate/samples/internal/go/v2/memcached_with_webhooks.go
+++ b/hack/generate/samples/internal/go/v2/memcached_with_webhooks.go
@@ -326,8 +326,10 @@ const reconcileFragment = `// Fetch the Memcached instance
 			log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
 			return ctrl.Result{}, err
 		}
-		// Spec updated - return and requeue
-		return ctrl.Result{Requeue: true}, nil
+		// Ask to requeue after 1 minute in order to give enough time for the
+		// pods be created on the cluster side and the operand be able 
+		// to do the next update step accurately.
+		return ctrl.Result{RequeueAfter: time.Minute }, nil
 	}
 
 	// Update the Memcached status with the pod names
@@ -417,6 +419,7 @@ const importsFragment = `
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"reflect"
+	"time"
 
 `
 
diff --git a/hack/generate/samples/internal/go/v3/memcached_with_webhooks.go b/hack/generate/samples/internal/go/v3/memcached_with_webhooks.go
index 525abae2..cf74fbf5 100644
--- a/hack/generate/samples/internal/go/v3/memcached_with_webhooks.go
+++ b/hack/generate/samples/internal/go/v3/memcached_with_webhooks.go
@@ -274,7 +274,7 @@ func GenerateMemcachedGoWithWebhooksSample(samplesPath string) {
 
 const rbacFragment = `
 //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;`
+//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch`
 
 const reconcileFragment = `// Fetch the Memcached instance
 	memcached := &cachev1alpha1.Memcached{}
@@ -320,8 +320,10 @@ const reconcileFragment = `// Fetch the Memcached instance
 			log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
 			return ctrl.Result{}, err
 		}
-		// Spec updated - return and requeue
-		return ctrl.Result{Requeue: true}, nil
+		// Ask to requeue after 1 minute in order to give enough time for the
+		// pods be created on the cluster side and the operand be able 
+		// to do the next update step accurately.
+		return ctrl.Result{RequeueAfter: time.Minute }, nil
 	}
 
 	// Update the Memcached status with the pod names
@@ -411,6 +413,7 @@ const importsFragment = `
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"reflect"
+	"time"
 
 `
 
diff --git a/hack/generate/samples/internal/helm/memcached.go b/hack/generate/samples/internal/helm/memcached.go
index 83f81e1b..b9cfc086 100644
--- a/hack/generate/samples/internal/helm/memcached.go
+++ b/hack/generate/samples/internal/helm/memcached.go
@@ -65,21 +65,16 @@ func (mh *MemcachedHelm) Run() {
 	// role and cause sanity testing to fail.
 	os.Setenv("KUBECONFIG", "broken_so_we_generate_static_default_rules")
 
-	log.Infof("creating the project")
+	helmChartPath := "../../../hack/generate/samples/internal/helm/testdata/memcached-0.0.1.tgz"
+	log.Infof("creating the project using the helm chart in: (%v)", helmChartPath)
 	err := mh.ctx.Init(
 		"--plugins", "helm",
-		"--domain", mh.ctx.Domain)
-	pkg.CheckError("creating the project", err)
-
-	helmChartPath := "../../../hack/generate/samples/internal/helm/testdata/memcached-0.0.1.tgz"
-	log.Infof("using the helm chart in: (%v)", helmChartPath)
-
-	err = mh.ctx.CreateAPI(
+		"--domain", mh.ctx.Domain,
 		"--group", mh.ctx.Group,
 		"--version", mh.ctx.Version,
 		"--kind", mh.ctx.Kind,
 		"--helm-chart", helmChartPath)
-	pkg.CheckError("scaffolding apis", err)
+	pkg.CheckError("creating the project", err)
 
 	log.Infof("customizing the sample")
 	err = testutils.ReplaceInFile(
diff --git a/hack/image/push-image-tags.sh b/hack/image/push-image-tags.sh
deleted file mode 100755
index b47b0593..00000000
--- a/hack/image/push-image-tags.sh
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-source hack/lib/image_lib.sh
-
-#
-# push_image_tags <source_image> <push_image>
-#
-# push_image_tags tags the source docker image with zero or more
-# image tags based on TravisCI environment variables and the
-# presence of git tags in the repository of the current working
-# directory. If a second argument is present, it will be used as
-# the base image name in pushed image tags.
-#
-function push_image_tags() {
-
-  source_image=$1; shift || fatal "${FUNCNAME} usage error"
-  push_image=$1; shift || push_image=$source_image
-
-  print_image_info $source_image
-
-  docker_login $push_image
-
-  check_can_push || return 0
-
-  images=$(get_image_tags $push_image)
-
-  for image in $images; do
-    docker tag "$source_image" "$image"
-    docker push "$image"
-  done
-}
-
-#
-# print_image_info <image_name>
-#
-# print_image_info prints helpful information about a docker
-# image.
-#
-function print_image_info() {
-  image_name=$1; shift || fatal "${FUNCNAME} usage error"
-  image_id=$(docker inspect "$image_name" -f "{{.Id}}")
-  image_created=$(docker inspect "$image_name" -f "{{.Created}}")
-
-  if [[ -n "$image_id" ]]; then
-    echo "Docker image info:"
-    echo "    Name:      $image_name"
-    echo "    ID:        $image_id"
-    echo "    Created:   $image_created"
-    echo ""
-  else
-    echo "Could not find docker image \"$image_name\""
-    return 1
-  fi
-}
-
-push_image_tags "$@"
diff --git a/hack/image/push-manifest-list.sh b/hack/image/push-manifest-list.sh
deleted file mode 100755
index b3af0694..00000000
--- a/hack/image/push-manifest-list.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env bash
-
-source hack/lib/image_lib.sh
-
-#
-# push_manifest_list <source_image> <push_image> [<arch1> <arch2> <archN>]
-#
-# push_manifest_list uses the pre-pushed images for each
-# supported architecture and pushes a manifest list for each
-# of the tags from the Travis CI envionment (created during
-# the image push job).
-#
-function push_manifest_list() {
-  push_image=$1; shift || fatal "${FUNCNAME} usage error"
-  arches=$@
-
-  docker_login $push_image
-
-  check_can_push || return 0
-
-  tags=$(get_image_tags)
-  for tag in $tags; do
-    images_with_arches=$(get_arch_images $push_image $tag $arches)
-    DOCKER_CLI_EXPERIMENTAL="enabled" docker manifest create $push_image:$tag $images_with_arches
-    DOCKER_CLI_EXPERIMENTAL="enabled" docker manifest push --purge $push_image:$tag
-  done
-
-}
-
-function get_arch_images(){
-  image=$1; shift || fatal "${FUNCNAME} usage error"
-  tag=$1; shift || fatal "${FUNCNAME} usage error"
-  arches="$@"
-  for arch in $arches; do
-    echo "$image-$arch:$tag"
-  done
-}
-
-push_manifest_list "$@"
diff --git a/hack/lib/image_lib.sh b/hack/lib/image_lib.sh
deleted file mode 100755
index e8acfa02..00000000
--- a/hack/lib/image_lib.sh
+++ /dev/null
@@ -1,126 +0,0 @@
-#!/usr/bin/env bash
-
-source hack/lib/common.sh
-
-semver_regex="^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
-
-# docker_login <image_name>
-#
-# docker_login performs a docker login for the server of the provided
-# image if the DOCKER_USERNAME and DOCKER_PASSWORD environment variables
-# are set.
-#
-function docker_login() {
-  image_name=$1; shift || fatal "${FUNCNAME} usage error"
-
-  if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then
-    server=$(docker_server_for_image $image_name)
-    echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin "$server"
-  fi
-}
-
-#
-# check_can_push
-#
-# check_can_push performs various checks to determine whether images
-# built from this commit should be pushed. It prints a message and
-# returns a failure code if any check doesn't pass.
-#
-function check_can_push() {
-  if [[ "$TRAVIS" != "true" ]]; then
-    echo "Detected execution in a non-TravisCI environment. Skipping image push."
-    return 1
-  elif [[ "$TRAVIS_EVENT_TYPE" == "pull_request" ]]; then
-    echo "Detected pull request commit. Skipping image push"
-    return 1
-  elif [[ ! -f "$HOME/.docker/config.json" ]]; then
-    echo "Docker login credentials required to push. Skipping image push."
-    return 1
-  fi
-}
-
-#
-# get_image_tags <image_name>
-#
-# get_image_tags returns a list of tags that are eligible to be pushed.
-# If an image name is passed as an argument, the full <name>:<tag> will
-# be returned for each eligible tag. The criteria is:
-#   1. Is TRAVIS_BRANCH set?                 => <image_name>:$TRAVIS_BRANCH
-#   2. Is TRAVIS_TAG highest semver release? => <image_name>:latest
-#
-function get_image_tags() {
-  image_name=$1
-  [[ -n "$image_name" ]] && image_name="${image_name}:"
-
-  # Tag `:$TRAVIS_BRANCH` if it is set.
-  # Note that if the build is for a tag, $TRAVIS_BRANCH is set
-  # to the tag, so this works in both cases
-  if [[ -n "$TRAVIS_BRANCH" ]]; then
-    echo "${image_name}${TRAVIS_BRANCH}"
-  fi
-
-  # Tag `:latest` if $TRAVIS_TAG is the highest semver tag found in
-  # the repository.
-  if is_latest_tag "$TRAVIS_TAG"; then
-    echo "${image_name}latest"
-  fi
-}
-
-#
-# docker_server_for_image <image_name>
-#
-# docker_server_for_image returns the server component of the image
-# name. If the image name does not contain a server component, an
-# empty string is returned.
-#
-function docker_server_for_image() {
-  image_name=$1; shift || fatal "${FUNCNAME} usage error"
-  IFS='/' read -r -a segments <<< "$image_name"
-  if [[ "${#segments[@]}" -gt "2" ]]; then
-    echo "${segments[0]}"
-  else
-    echo ""
-  fi
-}
-
-#
-# latest_git_version
-#
-# latest_git_version returns the highest semantic version
-# number found in the repository, with the form "vX.Y.Z".
-# Version numbers not matching the semver release format
-# are ignored.
-#
-function latest_git_version() {
-  git tag -l | egrep "${semver_regex}" | sort -V | tail -1
-}
-
-#
-# is_latest_tag <candidate>
-#
-# is_latest_tag returns whether the candidate tag matches
-# the latest tag from the git repository, based on semver.
-# To be the latest tag, the candidate must match the semver
-# release format.
-#
-function is_latest_tag() {
-  candidate=$1; shift || fatal "${FUNCNAME} usage error"
-  if ! [[ "$candidate" =~ $semver_regex ]]; then
-    return 1
-  fi
-
-  latest="$(latest_git_version)"
-  [[ -z "$latest" || "$candidate" == "$latest" ]]
-}
-
-#
-# load_image_if_kind <image tag>
-#
-# load_image_if_kind loads an image into all nodes in a kind cluster.
-#
-function load_image_if_kind() {
-  local cluster=${KIND_CLUSTER:-kind}
-  if [[ "$(kubectl config current-context)" == "kind-${cluster}" ]]; then
-    kind load docker-image --name "${cluster}" "$1"
-  fi
-}
diff --git a/images/ansible-operator/Dockerfile b/images/ansible-operator/Dockerfile
index 636a91c7..81ae0546 100644
--- a/images/ansible-operator/Dockerfile
+++ b/images/ansible-operator/Dockerfile
@@ -1,4 +1,24 @@
+# Build the manager binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/ansible-operator
+
+# Final image.
 FROM registry.access.redhat.com/ubi8/ubi:8.3-227
+ARG TARGETARCH
 
 RUN mkdir -p /etc/ansible \
   && echo "localhost ansible_connection=local" > /etc/ansible/hosts \
@@ -10,12 +30,10 @@ ENV HOME=/opt/ansible \
     USER_NAME=ansible \
     USER_UID=1001
 
-# Install python dependencies
-# Ensure fresh metadata rather than cached metadata in the base by running
-# yum clean all && rm -rf /var/yum/cache/* first
-RUN yum clean all && rm -rf /var/cache/yum/* \
-  && yum -y update \
-  && yum install -y libffi-devel openssl-devel python38-devel gcc python38-pip python38-setuptools \
+# Install python dependencies after freshening metadata.
+RUN dnf clean all && rm -rf /var/cache/dnf/* \
+  && dnf update -y \
+  && dnf install -y libffi-devel openssl-devel python38-devel gcc python38-pip python38-setuptools \
   && pip3 install --no-cache-dir \
     cryptography==3.3.2 \
     ansible-runner==1.3.4 \
@@ -25,9 +43,8 @@ RUN yum clean all && rm -rf /var/cache/yum/* \
     openshift==0.10.3 \
     ansible==2.9.15 \
     jmespath==0.10.0 \
-  && yum remove -y gcc libffi-devel openssl-devel python38-devel \
-  && yum clean all \
-  && rm -rf /var/cache/yum
+  && dnf remove -y gcc libffi-devel openssl-devel python38-devel \
+  && dnf clean all && rm -rf /var/cache/dnf/*
 
 # Ensure directory permissions are properly set
 RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin" >> /etc/passwd \
@@ -35,14 +52,12 @@ RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin"
   && chown -R ${USER_UID}:0 ${HOME} \
   && chmod -R ug+rwx ${HOME}
 
-RUN TINIARCH=$(case $(arch) in x86_64) echo -n amd64 ;; ppc64le) echo -n ppc64el ;; aarch64) echo -n arm64 ;; *) echo -n $(arch) ;; esac) \
-  && curl -L -o /tini https://github.com/krallin/tini/releases/latest/download/tini-$TINIARCH \
+RUN curl -L -o /tini https://github.com/krallin/tini/releases/latest/download/tini-${TARGETARCH} \
   && chmod +x /tini
 
 WORKDIR ${HOME}
 USER ${USER_UID}
 
-ARG BIN=bin/ansible-operator
-COPY $BIN /usr/local/bin/ansible-operator
+COPY --from=builder /workspace/build/ansible-operator /usr/local/bin/ansible-operator
 
 ENTRYPOINT ["/tini", "--", "/usr/local/bin/ansible-operator", "run", "--watches-file=./watches.yaml"]
diff --git a/images/helm-operator/Dockerfile b/images/helm-operator/Dockerfile
index 8ad5e303..7b674974 100644
--- a/images/helm-operator/Dockerfile
+++ b/images/helm-operator/Dockerfile
@@ -1,3 +1,22 @@
+# Build the manager binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/helm-operator
+
+# Final image.
 FROM registry.access.redhat.com/ubi8/ubi-minimal:8.3-230
 
 ENV HOME=/opt/helm \
@@ -9,7 +28,6 @@ RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin"
 WORKDIR ${HOME}
 USER ${USER_UID}
 
-ARG BIN=bin/helm-operator
-COPY $BIN /usr/local/bin/helm-operator
+COPY --from=builder /workspace/build/helm-operator /usr/local/bin/helm-operator
 
 ENTRYPOINT ["/usr/local/bin/helm-operator", "run", "--watches-file=./watches.yaml"]
diff --git a/images/operator-sdk/Dockerfile b/images/operator-sdk/Dockerfile
index c98e8245..71dbde57 100644
--- a/images/operator-sdk/Dockerfile
+++ b/images/operator-sdk/Dockerfile
@@ -1,8 +1,26 @@
+# Build the operator-sdk binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/operator-sdk
+
+# Final image.
 FROM registry.access.redhat.com/ubi8/ubi-minimal:8.3-230
 
 RUN microdnf install -y golang make which
 
-ARG BIN=bin/operator-sdk
-COPY $BIN /usr/local/bin/operator-sdk
+COPY --from=builder /workspace/build/operator-sdk /usr/local/bin/operator-sdk
 
 ENTRYPOINT ["/usr/local/bin/operator-sdk"]
diff --git a/internal/ansible/flags/flag.go b/internal/ansible/flags/flag.go
index 3fb6cd64..53ed8872 100644
--- a/internal/ansible/flags/flag.go
+++ b/internal/ansible/flags/flag.go
@@ -35,6 +35,7 @@ type Flags struct {
 	ProbeAddr               string
 	LeaderElectionID        string
 	LeaderElectionNamespace string
+	GracefulShutdownTimeout time.Duration
 	AnsibleArgs             string
 }
 
@@ -109,6 +110,12 @@ func (f *Flags) AddTo(flagSet *pflag.FlagSet) {
 			" holding the leader lock (required if running locally with leader"+
 			" election enabled).",
 	)
+	flagSet.DurationVar(&f.GracefulShutdownTimeout,
+		"graceful-shutdown-timeout",
+		30*time.Second,
+		"The amount of time that will be spent waiting"+
+			" for runners to gracefully exit.",
+	)
 	flagSet.StringVar(&f.AnsibleArgs,
 		"ansible-args",
 		"",
diff --git a/internal/cmd/ansible-operator/run/cmd.go b/internal/cmd/ansible-operator/run/cmd.go
index 257361a6..19724bac 100644
--- a/internal/cmd/ansible-operator/run/cmd.go
+++ b/internal/cmd/ansible-operator/run/cmd.go
@@ -109,6 +109,7 @@ func run(cmd *cobra.Command, f *flags.Flags) {
 		LeaderElectionResourceLock: resourcelock.ConfigMapsResourceLock,
 		LeaderElectionNamespace:    f.LeaderElectionNamespace,
 		ClientBuilder:              clientbuilder.NewUnstructedCached(),
+		GracefulShutdownTimeout:    &f.GracefulShutdownTimeout,
 	}
 
 	namespace, found := os.LookupEnv(k8sutil.WatchNamespaceEnvVar)
diff --git a/internal/cmd/operator-sdk/bundle/validate/cmd.go b/internal/cmd/operator-sdk/bundle/validate/cmd.go
index 4104733e..c604ace1 100644
--- a/internal/cmd/operator-sdk/bundle/validate/cmd.go
+++ b/internal/cmd/operator-sdk/bundle/validate/cmd.go
@@ -69,11 +69,11 @@ To list and run optional validators, which are specified by a label selector:
   operatorhub    name=operatorhub           OperatorHub.io metadata validation
                  suite=operatorframework
 
-To validate a bundle with a superset of requirements for Operator Framework:
+To validate a bundle against the entire suite of validators for Operator Framework, in addition to required bundle validators:
 	
   $ operator-sdk bundle validate ./bundle --select-optional suite=operatorframework
 
-To validate a bundle with a superset of requirements for operatorhub.io specifically:
+To validate a bundle against the validator for operatorhub.io specifically, in addition to required bundle validators:
 	
   $ operator-sdk bundle validate ./bundle --select-optional name=operatorhub
 `
diff --git a/internal/cmd/operator-sdk/generate/bundle/bundle.go b/internal/cmd/operator-sdk/generate/bundle/bundle.go
index 443c6c20..b731e71e 100644
--- a/internal/cmd/operator-sdk/generate/bundle/bundle.go
+++ b/internal/cmd/operator-sdk/generate/bundle/bundle.go
@@ -20,29 +20,42 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
-	"sort"
 	"strings"
+	"text/template"
 
 	"github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3"
 	"github.com/operator-framework/operator-registry/pkg/lib/bundle"
+	log "github.com/sirupsen/logrus"
 	"sigs.k8s.io/yaml"
 
 	metricsannotations "github.com/operator-framework/operator-sdk/internal/annotations/metrics"
-	scorecardannotations "github.com/operator-framework/operator-sdk/internal/annotations/scorecard"
 	genutil "github.com/operator-framework/operator-sdk/internal/cmd/operator-sdk/generate/internal"
 	gencsv "github.com/operator-framework/operator-sdk/internal/generate/clusterserviceversion"
 	"github.com/operator-framework/operator-sdk/internal/generate/clusterserviceversion/bases"
 	"github.com/operator-framework/operator-sdk/internal/generate/collector"
 	"github.com/operator-framework/operator-sdk/internal/registry"
 	"github.com/operator-framework/operator-sdk/internal/scorecard"
-	"github.com/operator-framework/operator-sdk/internal/util/projutil"
 )
 
 const (
 	longHelp = `
-Running 'generate bundle' is the first step to publishing your operator to a catalog and/or deploying it with OLM.
-This command generates a set of bundle manifests, metadata, and a bundle.Dockerfile for your operator.
-Typically one would run 'generate kustomize manifests' first to (re)generate kustomize bases consumed by this command.
+Running 'generate bundle' is the first step to publishing your operator to a catalog and deploying it with OLM.
+This command both generates and packages files into an on-disk representation of an operator called a bundle.
+A bundle consists of a ClusterServiceVersion (CSV), CustomResourceDefinitions (CRDs),
+manifests not part of the CSV but required by the operator, some metadata (annotations.yaml),
+and a bundle.Dockerfile to build a bundle image.
+
+A CSV manifest is generated by collecting data from the set of manifests passed to this command (see below),
+such as CRDs, RBAC, etc., and applying that data to a "base" CSV manifest. This base CSV can contain metadata,
+added by hand or by the 'generate kustomize manifests' command, and can be passed in like any other manifest
+(see below) or by file at the exact path '<kustomize-dir>/bases/<package-name>.clusterserviceversion.yaml'.
+Be aware that 'generate bundle' idempotently regenerates a bundle, so all non-metadata values in a base
+will be overwritten. If no base was passed in, input manifest data will be applied to an empty CSV.
+
+There are two ways to pass the to-be-bundled set of manifests to this command: stdin via a Unix pipe,
+or in a directory using '--input-dir'. See command help for more information on these modes.
+Passing a directory is useful for running 'generate bundle' outside of a project or within a project
+that does not use kustomize and/or contains cluster-ready manifests on disk.
 
 Set '--version' to supply a semantic version for your bundle if you are creating one
 for the first time or upgrading an existing one.
@@ -55,49 +68,44 @@ https://github.com/operator-framework/operator-registry/#manifest-format
 `
 
 	examples = `
-  # Generate bundle files and build your bundle image with these 'make' recipes:
-  $ make bundle
-  $ export USERNAME=<quay-namespace>
-  $ export BUNDLE_IMG=quay.io/$USERNAME/memcached-operator-bundle:v0.0.1
-  $ make bundle-build BUNDLE_IMG=$BUNDLE_IMG
-
-  # The above recipe runs the following commands manually. First it creates bundle
-  # manifests, metadata, and a bundle.Dockerfile:
-  $ make manifests
-  /home/user/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
-  $ operator-sdk generate kustomize manifests
-
-  Display name for the operator (required):
-  > memcached-operator
-  ...
-
+  # If running within a project or in a project that uses kustomize to generate manifests,
+	# make sure a kustomize directory exists that looks like the following 'config/manifests' directory:
   $ tree config/manifests
   config/manifests
   ├── bases
   │   └── memcached-operator.clusterserviceversion.yaml
   └── kustomization.yaml
-  $ kustomize build config/manifests | operator-sdk generate bundle --overwrite --version 0.0.1
-  Generating bundle manifest version 0.0.1
+
+  # Generate a 0.0.1 bundle by passing manifests to stdin:
+  $ kustomize build config/manifests | operator-sdk generate bundle --version 0.0.1
+  Generating bundle version 0.0.1
+  ...
+
+  # If running outside of a project or in a project that does not use kustomize to generate manifests,
+	# make sure cluster-ready manifests are available on disk:
+  $ tree deploy/
+  deploy/
+  ├── crds
+  │   └── cache.my.domain_memcacheds.yaml
+  ├── deployment.yaml
+  ├── role.yaml
+  ├── role_binding.yaml
+  ├── service_account.yaml
+  └── webhooks.yaml
+
+  # Generate a 0.0.1 bundle by passing manifests by dir:
+  $ operator-sdk generate bundle --input-dir deploy --version 0.0.1
+  Generating bundle version 0.0.1
   ...
 
-  # After running the above commands, you should see this directory structure:
-  $ tree bundle
-  bundle
+  # After running in either of the above modes, you should see this directory structure:
+  $ tree bundle/
+  bundle/
   ├── manifests
   │   ├── cache.my.domain_memcacheds.yaml
   │   └── memcached-operator.clusterserviceversion.yaml
   └── metadata
       └── annotations.yaml
-
-  # Then it validates your bundle files and builds your bundle image:
-  $ operator-sdk bundle validate ./bundle
-  $ docker build -f bundle.Dockerfile -t $BUNDLE_IMG .
-  Sending build context to Docker daemon  42.33MB
-  Step 1/9 : FROM scratch
-  ...
-
-  # You can then push your bundle image:
-  $ make docker-push IMG=$BUNDLE_IMG
 `
 )
 
@@ -120,17 +128,18 @@ func (c bundleCmd) validateManifests() (err error) {
 		}
 	}
 
-	if c.kustomizeDir == "" {
-		return errors.New("--kustomize-dir must be set")
-	}
-
-	if !genutil.IsPipeReader() {
-		if c.deployDir == "" {
-			return errors.New("--deploy-dir must be set if not reading from stdin")
-		}
-		if c.crdsDir == "" {
-			return errors.New("--crds-dir must be set if not reading from stdin")
-		}
+	// The three possible usage modes (stdin, inputDir, and legacy dirs) are mutually exclusive
+	// and one must be chosen.
+	isPipeReader := genutil.IsPipeReader()
+	isInputDir := c.inputDir != ""
+	isLegacyDirs := c.deployDir != "" || c.crdsDir != ""
+	switch {
+	case !(isPipeReader || isInputDir || isLegacyDirs):
+		return errors.New("one of stdin, --input-dir, or --deploy-dir (and optionally --crds-dir) must be set")
+	case isPipeReader && (isInputDir || isLegacyDirs):
+		return errors.New("none of --input-dir, --deploy-dir, or --crds-dir may be set if reading from stdin")
+	case isInputDir && isLegacyDirs:
+		return errors.New("only one of --input-dir or --deploy-dir (and optionally --crds-dir) may be set if not reading from stdin")
 	}
 
 	if c.stdout {
@@ -145,45 +154,39 @@ func (c bundleCmd) validateManifests() (err error) {
 // runManifests generates bundle manifests.
 func (c bundleCmd) runManifests() (err error) {
 
-	if !c.quiet && !c.stdout {
-		if c.version == "" {
-			fmt.Println("Generating bundle manifests")
-		} else {
-			fmt.Println("Generating bundle manifests version", c.version)
-		}
-	}
+	c.println("Generating bundle manifests")
 
-	if c.inputDir == "" {
-		c.inputDir = defaultRootDir
-	}
-	if !c.stdout {
-		if c.outputDir == "" {
-			c.outputDir = defaultRootDir
-		}
+	if !c.stdout && c.outputDir == "" {
+		c.outputDir = defaultRootDir
 	}
 
 	col := &collector.Manifests{}
-	if genutil.IsPipeReader() {
-		if err := col.UpdateFromReader(os.Stdin); err != nil {
-			return err
-		}
+	switch {
+	case genutil.IsPipeReader():
+		err = col.UpdateFromReader(os.Stdin)
+	case c.deployDir != "" && c.crdsDir != "":
+		err = col.UpdateFromDirs(c.deployDir, c.crdsDir)
+	case c.deployDir != "": // If only deployDir is set, use as input dir.
+		c.inputDir = c.deployDir
+		fallthrough
+	case c.inputDir != "":
+		err = col.UpdateFromDir(c.inputDir)
 	}
-	if c.deployDir != "" {
-		if err := col.UpdateFromDirs(c.deployDir, c.crdsDir); err != nil {
-			return err
-		}
+	if err != nil {
+		return err
 	}
 
-	// If no CSV is passed via stdin, an on-disk base is expected at basePath.
-	if len(col.ClusterServiceVersions) == 0 {
-		basePath := filepath.Join(c.kustomizeDir, "bases", c.packageName+".clusterserviceversion.yaml")
-		if genutil.IsExist(basePath) {
-			base, err := bases.ClusterServiceVersion{BasePath: basePath}.GetBase()
-			if err != nil {
-				return fmt.Errorf("error reading CSV base: %v", err)
-			}
-			col.ClusterServiceVersions = append(col.ClusterServiceVersions, *base)
+	// If no CSV was initially read, a kustomize base can be used at the default base path.
+	// Only read from kustomizeDir if a base exists so users can still generate a barebones CSV.
+	baseCSVPath := filepath.Join(c.kustomizeDir, "bases", c.packageName+".clusterserviceversion.yaml")
+	if len(col.ClusterServiceVersions) == 0 && genutil.IsExist(baseCSVPath) {
+		base, err := bases.ClusterServiceVersion{BasePath: baseCSVPath}.GetBase()
+		if err != nil {
+			return fmt.Errorf("error reading CSV base: %v", err)
 		}
+		col.ClusterServiceVersions = append(col.ClusterServiceVersions, *base)
+	} else {
+		c.println("Building a ClusterServiceVersion without an existing base")
 	}
 
 	var opts []gencsv.Option
@@ -221,15 +224,14 @@ func (c bundleCmd) runManifests() (err error) {
 		return fmt.Errorf("error writing bundle scorecard config: %v", err)
 	}
 
-	if !c.quiet && !c.stdout {
-		fmt.Println("Bundle manifests generated successfully in", c.outputDir)
-	}
+	c.println("Bundle manifests generated successfully in", c.outputDir)
 
 	return nil
 }
 
 // writeScorecardConfig writes cfg to dir at the hard-coded config path 'config.yaml'.
 func writeScorecardConfig(dir string, cfg v1alpha3.Configuration) error {
+	// Skip writing if config is empty.
 	if cfg.Metadata.Name == "" {
 		return nil
 	}
@@ -250,140 +252,127 @@ func writeScorecardConfig(dir string, cfg v1alpha3.Configuration) error {
 // runMetadata generates a bundle.Dockerfile and bundle metadata.
 func (c bundleCmd) runMetadata() error {
 
-	directory := c.inputDir
-	if directory == "" {
-		// There may be no existing bundle at the default path, so assume manifests
-		// only exist in the output directory.
-		defaultDirectory := filepath.Join(defaultRootDir, bundle.ManifestsDir)
-		if c.outputDir != "" && genutil.IsNotExist(defaultDirectory) {
-			directory = filepath.Join(c.outputDir, bundle.ManifestsDir)
-		} else {
-			directory = defaultDirectory
-		}
-	} else {
-		directory = filepath.Join(directory, bundle.ManifestsDir)
+	c.println("Generating bundle metadata")
+
+	if c.outputDir == "" {
+		c.outputDir = defaultRootDir
 	}
-	outputDir := c.outputDir
-	if filepath.Clean(outputDir) == filepath.Clean(directory) {
-		outputDir = ""
+	if err := os.MkdirAll(c.outputDir, 0755); err != nil {
+		return err
 	}
 
-	return c.generateMetadata(directory, outputDir)
-}
-
-// generateMetadata wraps the operator-registry bundle Dockerfile/metadata generator.
-func (c bundleCmd) generateMetadata(manifestsDir, outputDir string) error {
-
-	metadataExists := isMetatdataExist(outputDir, manifestsDir)
-	err := bundle.GenerateFunc(manifestsDir, outputDir, c.packageName, c.channels, c.defaultChannel, c.overwrite)
-	if err != nil {
-		return fmt.Errorf("error generating bundle metadata: %v", err)
+	// If metadata already exists, only overwrite it if directed to.
+	bundleRoot := c.inputDir
+	if bundleRoot == "" {
+		bundleRoot = c.outputDir
 	}
-
-	// Add SDK annotations/labels if metadata did not exist before or when overwrite is true.
-	if c.overwrite || !metadataExists {
-		bundleRoot := outputDir
-		if bundleRoot == "" {
-			bundleRoot = filepath.Dir(manifestsDir)
-		}
-
-		if err = updateMetadata(c.layout, bundleRoot); err != nil {
+	if _, _, err := registry.FindBundleMetadata(bundleRoot); err != nil {
+		merr := registry.MetadataNotFoundError("")
+		if !errors.As(err, &merr) {
 			return err
 		}
+	} else if !c.overwrite {
+		return nil
 	}
-	return nil
-}
 
-// TODO(estroz): these updates need to be atomic because the bundle's Dockerfile and annotations.yaml
-// cannot be out-of-sync.
-func updateMetadata(layout, bundleRoot string) error {
-	bundleLabels := metricsannotations.MakeBundleMetadataLabels(layout)
-	for key, value := range scorecardannotations.MakeBundleMetadataLabels(scorecard.DefaultConfigDir) {
-		if _, hasKey := bundleLabels[key]; hasKey {
-			return fmt.Errorf("internal error: duplicate bundle annotation key %s", key)
-		}
-		bundleLabels[key] = value
+	// Create annotation values for both bundle.Dockerfile and annotations.yaml, which should
+	// hold the same set of values always.
+	values := annotationsValues{
+		BundleDir:      c.outputDir,
+		PackageName:    c.packageName,
+		Channels:       c.channels,
+		DefaultChannel: c.defaultChannel,
+	}
+	for key, value := range metricsannotations.MakeBundleMetadataLabels(c.layout) {
+		values.OtherLabels = append(values.OtherLabels, fmt.Sprintf("%s=%s", key, value))
 	}
 
-	// Write labels to bundle Dockerfile.
-	if err := rewriteDockerfileLabels(bundle.DockerFile, bundleLabels); err != nil {
-		return fmt.Errorf("error writing LABEL's in %s: %v", bundle.DockerFile, err)
+	// Write each file.
+	metadataDir := filepath.Join(c.outputDir, "metadata")
+	if err := os.MkdirAll(metadataDir, 0755); err != nil {
+		return err
 	}
-	if err := rewriteAnnotations(bundleRoot, bundleLabels); err != nil {
-		return fmt.Errorf("error writing LABEL's in bundle metadata: %v", err)
+	templateMap := map[string]*template.Template{
+		"bundle.Dockerfile":                            dockerfileTemplate,
+		filepath.Join(metadataDir, "annotations.yaml"): annotationsTemplate,
 	}
-
-	// Add a COPY for the scorecard config to bundle Dockerfile.
-	// TODO: change input config path to be a flag-based value.
-	localScorecardConfigPath := filepath.Join(bundleRoot, filepath.FromSlash(scorecard.DefaultConfigDir))
-	err := writeDockerfileCOPYScorecardConfig(bundle.DockerFile, localScorecardConfigPath)
-	if err != nil {
-		return fmt.Errorf("error writing scorecard config COPY in %s: %v", bundle.DockerFile, err)
+	for path, tmpl := range templateMap {
+		c.println("Creating", path)
+		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if err := f.Close(); err != nil {
+				log.Error(err)
+			}
+		}()
+		if err := tmpl.Execute(f, values); err != nil {
+			return err
+		}
 	}
 
-	return nil
-}
+	c.println("Bundle metadata generated successfully")
 
-// writeDockerfileCOPYScorecardConfig checks if bundle.Dockerfile and scorecard config exists in
-// the operator project. If it does, it injects the scorecard configuration into bundle image.
-func writeDockerfileCOPYScorecardConfig(dockerfileName, localConfigDir string) error {
-	if genutil.IsExist(bundle.DockerFile) && genutil.IsExist(localConfigDir) {
-		scorecardFileContent := fmt.Sprintf("COPY %s %s\n", localConfigDir, "/"+scorecard.DefaultConfigDir)
-		return projutil.RewriteFileContents(dockerfileName, "COPY", scorecardFileContent)
-	}
 	return nil
 }
 
-// isMetatdataExist returns true if bundle.Dockerfile and metadataDir exist, if not
-// it returns false.
-func isMetatdataExist(outputDir, manifestsDir string) bool {
-	var annotationsDir string
-	if outputDir == "" {
-		annotationsDir = filepath.Dir(manifestsDir) + bundle.MetadataDir
-	} else {
-		annotationsDir = outputDir + bundle.MetadataDir
-	}
-
-	if genutil.IsNotExist(bundle.DockerFile) || genutil.IsNotExist(annotationsDir) {
-		return false
-	}
-	return true
+// values to populate bundle metadata/Dockerfile.
+type annotationsValues struct {
+	BundleDir      string
+	PackageName    string
+	Channels       string
+	DefaultChannel string
+	OtherLabels    []string
 }
 
-func rewriteDockerfileLabels(dockerfileName string, kvs map[string]string) error {
-	var labelStrings []string
-	for key, value := range kvs {
-		labelStrings = append(labelStrings, fmt.Sprintf("LABEL %s=%s\n", key, value))
-	}
-	sort.Strings(labelStrings)
-	var newBundleLabels strings.Builder
-	for _, line := range labelStrings {
-		newBundleLabels.WriteString(line)
-	}
-
-	return projutil.RewriteFileContents(dockerfileName, "LABEL", newBundleLabels.String())
+// Transform a Dockerfile label to a YAML kv.
+var funcs = template.FuncMap{
+	"toYAML": func(s string) string { return strings.ReplaceAll(s, "=", ": ") },
 }
 
-func rewriteAnnotations(bundleRoot string, kvs map[string]string) error {
-	annotations, annotationsPath, err := registry.FindBundleMetadata(bundleRoot)
-	if err != nil {
-		return err
-	}
-
-	for key, value := range kvs {
-		annotations[key] = value
-	}
-	annotationsFile := bundle.AnnotationMetadata{
-		Annotations: annotations,
-	}
-	b, err := yaml.Marshal(annotationsFile)
-	if err != nil {
-		return err
-	}
-
-	mode := os.FileMode(0666)
-	if info, err := os.Stat(annotationsPath); err == nil {
-		mode = info.Mode()
-	}
-	return ioutil.WriteFile(annotationsPath, b, mode)
-}
+// Template for bundle.Dockerfile, containing scorecard labels.
+var dockerfileTemplate = template.Must(template.New("").Funcs(funcs).Parse(`FROM scratch
+
+# Core bundle labels.
+LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
+LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
+LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
+LABEL operators.operatorframework.io.bundle.package.v1={{ .PackageName }}
+LABEL operators.operatorframework.io.bundle.channels.v1={{ .Channels }}
+{{- if .DefaultChannel }}
+LABEL operators.operatorframework.io.bundle.channel.default.v1={{ .DefaultChannel }}
+{{- end }}
+{{- range $i, $l := .OtherLabels }}
+LABEL {{ $l }}
+{{- end }}
+
+# Labels for testing.
+LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1
+LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Copy files to locations specified by labels.
+COPY {{ .BundleDir }}/manifests /manifests/
+COPY {{ .BundleDir }}/metadata /metadata/
+COPY {{ .BundleDir }}/tests/scorecard /tests/scorecard/
+`))
+
+// Template for annotations.yaml, containing scorecard labels.
+var annotationsTemplate = template.Must(template.New("").Funcs(funcs).Parse(`annotations:
+  # Core bundle annotations.
+  operators.operatorframework.io.bundle.mediatype.v1: registry+v1
+  operators.operatorframework.io.bundle.manifests.v1: manifests/
+  operators.operatorframework.io.bundle.metadata.v1: metadata/
+  operators.operatorframework.io.bundle.package.v1: {{ .PackageName }}
+  operators.operatorframework.io.bundle.channels.v1: {{ .Channels }}
+  {{- if .DefaultChannel }}
+  operators.operatorframework.io.bundle.channel.default.v1: {{ .DefaultChannel }}
+  {{- end }}
+  {{- range $i, $l := .OtherLabels }}
+  {{ toYAML $l }}
+  {{- end }}
+
+  # Annotations for testing.
+  operators.operatorframework.io.test.mediatype.v1: scorecard+v1
+  operators.operatorframework.io.test.config.v1: tests/scorecard/
+`))
diff --git a/internal/cmd/operator-sdk/generate/bundle/cmd.go b/internal/cmd/operator-sdk/generate/bundle/cmd.go
index 8d87c04e..ec85aab4 100644
--- a/internal/cmd/operator-sdk/generate/bundle/cmd.go
+++ b/internal/cmd/operator-sdk/generate/bundle/cmd.go
@@ -67,6 +67,11 @@ func NewCmd() *cobra.Command {
 			if !fs.Changed("metadata") && !fs.Changed("manifests") {
 				c.manifests = true
 				c.metadata = true
+				if c.version == "" {
+					c.println("Generating bundle")
+				} else {
+					c.println("Generating bundle version", c.version)
+				}
 			}
 
 			if err := c.setDefaults(); err != nil {
@@ -97,10 +102,6 @@ func NewCmd() *cobra.Command {
 		},
 	}
 
-	cmd.Flags().StringVar(&c.kustomizeDir, "kustomize-dir", filepath.Join("config", "manifests"),
-		"Directory containing kustomize bases and a kustomization.yaml for operator-framework manifests")
-	cmd.Flags().BoolVar(&c.stdout, "stdout", false, "Write bundle manifest to stdout")
-
 	c.addFlagsTo(cmd.Flags())
 
 	return cmd
@@ -112,16 +113,31 @@ func (c *bundleCmd) addFlagsTo(fs *pflag.FlagSet) {
 
 	fs.StringVarP(&c.version, "version", "v", "", "Semantic version of the operator in the generated bundle. "+
 		"Only set if creating a new bundle or upgrading your operator")
-	fs.StringVar(&c.inputDir, "input-dir", "", "Directory to read an existing bundle from. "+
-		"This directory is the parent of your bundle 'manifests' directory, and different from --deploy-dir")
+	fs.StringVar(&c.inputDir, "input-dir", "", "Directory to read cluster-ready operator manifests from. "+
+		"This option is mutually exclusive with --deploy-dir/--crds-dir and piping to stdin. "+
+		"This option should not be passed an existing bundle directory, as this bundle will not contain the correct "+
+		"set of manifests required to generate a CSV. Use --kustomize-dir to pass a base CSV")
 	fs.StringVar(&c.outputDir, "output-dir", "", "Directory to write the bundle to")
-	fs.StringVar(&c.deployDir, "deploy-dir", "", "Root directory for operator manifests such as "+
-		"Deployments and RBAC, ex. 'deploy'. This directory is different from that passed to --input-dir")
-	fs.StringVar(&c.crdsDir, "crds-dir", "", "Root directory for CustomResoureDefinition manifests")
+	// TODO(estroz): deprecate this in favor of --intput-dir.
+	fs.StringVar(&c.deployDir, "deploy-dir", "", "Directory to read cluster-ready operator manifests from. "+
+		"If --crds-dir is not set, CRDs are ready from this directory. "+
+		"This option is mutually exclusive with --input-dir and piping to stdin")
+	// TODO(estroz): deprecate this in favor of --intput-dir.
+	fs.StringVar(&c.crdsDir, "crds-dir", "", "Directory to read cluster-ready CustomResoureDefinition manifests from. "+
+		"This option can only be used if --deploy-dir is set")
+	fs.StringVar(&c.kustomizeDir, "kustomize-dir", filepath.Join("config", "manifests"),
+		"Directory containing kustomize bases in a \"bases\" dir and a kustomization.yaml for operator-framework manifests")
 	fs.StringVar(&c.channels, "channels", "alpha", "A comma-separated list of channels the bundle belongs to")
 	fs.StringVar(&c.defaultChannel, "default-channel", "", "The default channel for the bundle")
 	fs.BoolVar(&c.overwrite, "overwrite", true, "Overwrite the bundle's metadata and Dockerfile if they exist")
 	fs.BoolVarP(&c.quiet, "quiet", "q", false, "Run in quiet mode")
+	fs.BoolVar(&c.stdout, "stdout", false, "Write bundle manifest to stdout")
 
 	fs.StringVar(&c.packageName, "package", "", "Bundle's package name")
 }
+
+func (c bundleCmd) println(a ...interface{}) {
+	if !(c.quiet || c.stdout) {
+		fmt.Println(a...)
+	}
+}
diff --git a/internal/cmd/operator-sdk/generate/internal/genutil.go b/internal/cmd/operator-sdk/generate/internal/genutil.go
index 03cbe285..83a089c9 100644
--- a/internal/cmd/operator-sdk/generate/internal/genutil.go
+++ b/internal/cmd/operator-sdk/generate/internal/genutil.go
@@ -73,19 +73,30 @@ func WriteObjectsToFiles(dir string, objs ...client.Object) error {
 	}
 
 	seenFiles := make(map[string]struct{})
+	// Use the number of dupliates in file names so users can debug duplicate file behavior.
+	dupCount := 0
 	for _, obj := range objs {
 		var fileName string
 		switch t := obj.(type) {
 		case *apiextv1.CustomResourceDefinition:
-			fileName = makeCRDFileName(t.Spec.Group, t.Spec.Names.Plural)
+			if t.Spec.Group != "" && t.Spec.Names.Plural != "" {
+				fileName = makeCRDFileName(t.Spec.Group, t.Spec.Names.Plural)
+			} else {
+				fileName = makeObjectFileName(t)
+			}
 		case *apiextv1beta1.CustomResourceDefinition:
-			fileName = makeCRDFileName(t.Spec.Group, t.Spec.Names.Plural)
+			if t.Spec.Group != "" && t.Spec.Names.Plural != "" {
+				fileName = makeCRDFileName(t.Spec.Group, t.Spec.Names.Plural)
+			} else {
+				fileName = makeObjectFileName(t)
+			}
 		default:
 			fileName = makeObjectFileName(t)
 		}
 
 		if _, hasFile := seenFiles[fileName]; hasFile {
-			return fmt.Errorf("duplicate file cannot be written: %s", fileName)
+			fileName = fmt.Sprintf("dup%d_%s", dupCount, fileName)
+			dupCount++
 		}
 		if err := writeObjectToFile(dir, obj, fileName); err != nil {
 			return err
diff --git a/internal/cmd/operator-sdk/generate/packagemanifests/cmd.go b/internal/cmd/operator-sdk/generate/packagemanifests/cmd.go
index 9e3e2e46..ca1ea512 100644
--- a/internal/cmd/operator-sdk/generate/packagemanifests/cmd.go
+++ b/internal/cmd/operator-sdk/generate/packagemanifests/cmd.go
@@ -87,14 +87,15 @@ func NewCmd() *cobra.Command {
 func (c *packagemanifestsCmd) addFlagsTo(fs *pflag.FlagSet) {
 	fs.StringVarP(&c.version, "version", "v", "", "Semantic version of the packaged operator")
 	fs.StringVar(&c.fromVersion, "from-version", "", "Semantic version of the operator being upgraded from")
-	fs.StringVar(&c.inputDir, "input-dir", "", "Directory to read existing package manifests from. "+
+	fs.StringVar(&c.inputDir, "input-dir", defaultRootDir, "Directory to read existing package manifests from. "+
 		"This directory is the parent of individual versioned package directories, and different from --deploy-dir")
 	fs.StringVar(&c.outputDir, "output-dir", "", "Directory in which to write package manifests")
 	fs.StringVar(&c.kustomizeDir, "kustomize-dir", filepath.Join("config", "manifests"),
-		"Directory containing kustomize bases and a kustomization.yaml for operator-framework manifests")
-	fs.StringVar(&c.deployDir, "deploy-dir", "", "Root directory for operator manifests such as "+
-		"Deployments and RBAC, ex. 'deploy'. This directory is different from that passed to --input-dir")
-	fs.StringVar(&c.crdsDir, "crds-dir", "", "Root directory for CustomResoureDefinition manifests")
+		"Directory containing kustomize bases in a \"bases\" dir and a kustomization.yaml for operator-framework manifests")
+	fs.StringVar(&c.deployDir, "deploy-dir", "", "Directory to read cluster-ready operator manifests from. "+
+		"If --crds-dir is not set, CRDs are ready from this directory")
+	fs.StringVar(&c.crdsDir, "crds-dir", "", "Directory to read cluster-ready CustomResoureDefinition manifests from. "+
+		"This option can only be used if --deploy-dir is set")
 	fs.StringVar(&c.channelName, "channel", "", "Channel name for the generated package")
 	fs.BoolVar(&c.isDefaultChannel, "default-channel", false, "Use the channel passed to --channel "+
 		"as the package manifest file's default channel")
@@ -105,3 +106,9 @@ func (c *packagemanifestsCmd) addFlagsTo(fs *pflag.FlagSet) {
 
 	fs.StringVar(&c.packageName, "package", "", "Package name")
 }
+
+func (c packagemanifestsCmd) println(a ...interface{}) {
+	if !(c.quiet || c.stdout) {
+		fmt.Println(a...)
+	}
+}
diff --git a/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go b/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go
index 53f3c728..c04fc405 100644
--- a/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go
+++ b/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests.go
@@ -33,10 +33,22 @@ const (
 Note: while the package manifests format is not yet deprecated, the operator-framework is migrated
 towards using bundles by default. Run 'operator-sdk generate bundle -h' for more information.
 
-Running 'generate packagemanifests' is the first step to publishing your operator to a catalog and/or deploying
-it with OLM. This command generates a set of manifests in a versioned directory and a package manifest file for
-your operator. Typically one would run 'generate kustomize manifests' first to (re)generate kustomize bases
-consumed by this command.
+This command generates a set of manifests in a versioned directory and a package manifest file for
+your operator. Each versioned directory consists of a ClusterServiceVersion (CSV), CustomResourceDefinitions (CRDs),
+and manifests not part of the CSV but required by the operator.
+
+A CSV manifest is generated by collecting data from the set of manifests passed to this command (see below),
+such as CRDs, RBAC, etc., and applying that data to a "base" CSV manifest. This base CSV can contain metadata,
+added by hand or by the 'generate kustomize manifests' command, and can be passed in like any other manifest
+(see below) or by file at the exact path '<kustomize-dir>/bases/<package-name>.clusterserviceversion.yaml'.
+Be aware that 'generate packagemanifests' idempotently regenerates a packagemanifests directory,
+so all non-metadata values in a base will be overwritten. If no base was passed in, input manifest data
+will be applied to an empty CSV.
+
+There are two ways to pass the to-be-packaged set of manifests to this command: stdin via a Unix pipe,
+or in a directory using '--input-dir'. See command help for more information on these modes.
+Passing a directory is useful for running 'generate packagemanifests' outside of a project or within a project
+that does not use kustomize and/or contains cluster-ready manifests on disk.
 
 Set '--version' to supply a semantic version for your new package.
 
@@ -45,27 +57,37 @@ https://github.com/operator-framework/operator-registry/#manifest-format
 `
 
 	examples = `
-  # Generate manifests then create the package manifests base:
-  $ make manifests
-  /home/user/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
-  $ operator-sdk generate kustomize manifests
-
-  Display name for the operator (required):
-  > memcached-operator
-  ...
-
+  # If running within a project, make sure a 'config' kustomize directory exists and has a 'config/manifests':
   $ tree config/manifests
   config/manifests
   ├── bases
   │   └── memcached-operator.clusterserviceversion.yaml
   └── kustomization.yaml
+
+  # Generate a 0.0.1 packagemanifests by passing manifests to stdin:
   $ kustomize build config/manifests | operator-sdk generate packagemanifests --version 0.0.1
   Generating package manifests version 0.0.1
   ...
 
-  # After running the above commands, you should see this directory structure:
-  $ tree packagemanifests
-  packagemanifests
+  # If running outside of a project, make sure cluster-ready manifests are available on disk:
+  $ tree deploy/
+  deploy/
+  ├── crds
+  │   └── cache.my.domain_memcacheds.yaml
+  ├── deployment.yaml
+  ├── role.yaml
+  ├── role_binding.yaml
+  ├── service_account.yaml
+  └── webhooks.yaml
+
+  # Generate a 0.0.1 packagemanifests by passing manifests by dir:
+  $ operator-sdk generate packagemanifests --deploy-dir deploy --version 0.0.1
+  Generating package manifests version 0.0.1
+  ...
+
+  # After running in either of the above modes, you should see this directory structure:
+  $ tree packagemanifests/
+  packagemanifests/
   ├── 0.0.1
   │   ├── cache.my.domain_memcacheds.yaml
   │   └── memcached-operator.clusterserviceversion.yaml
@@ -82,14 +104,10 @@ func (c *packagemanifestsCmd) setDefaults() (err error) {
 		return err
 	}
 
-	if c.inputDir == "" {
-		c.inputDir = defaultRootDir
-	}
-	if !c.stdout {
-		if c.outputDir == "" {
-			c.outputDir = defaultRootDir
-		}
+	if !c.stdout && c.outputDir == "" {
+		c.outputDir = defaultRootDir
 	}
+
 	c.generator = genpkg.NewGenerator()
 
 	return nil
@@ -115,9 +133,6 @@ func (c packagemanifestsCmd) validate() error {
 	if c.inputDir == "" {
 		return errors.New("--input-dir must be set")
 	}
-	if c.kustomizeDir == "" {
-		return errors.New("--kustomize-dir must be set")
-	}
 
 	if !genutil.IsPipeReader() {
 		if c.deployDir == "" {
@@ -144,9 +159,7 @@ func (c packagemanifestsCmd) validate() error {
 // run generates package manifests.
 func (c packagemanifestsCmd) run() error {
 
-	if !c.quiet && !c.stdout {
-		fmt.Println("Generating package manifests version", c.version)
-	}
+	c.println("Generating package manifests version", c.version)
 
 	if err := c.generatePackageManifest(); err != nil {
 		return err
@@ -164,16 +177,17 @@ func (c packagemanifestsCmd) run() error {
 		}
 	}
 
-	// If no CSV is passed via stdin, an on-disk base is expected at basePath.
-	if len(col.ClusterServiceVersions) == 0 {
-		basePath := filepath.Join(c.kustomizeDir, "bases", c.packageName+".clusterserviceversion.yaml")
-		if genutil.IsExist(basePath) {
-			base, err := bases.ClusterServiceVersion{BasePath: basePath}.GetBase()
-			if err != nil {
-				return fmt.Errorf("error reading CSV base: %v", err)
-			}
-			col.ClusterServiceVersions = append(col.ClusterServiceVersions, *base)
+	// If no CSV was initially read, a kustomize base can be used at the default base path.
+	// Only read from kustomizeDir if a base exists so users can still generate a barebones CSV.
+	baseCSVPath := filepath.Join(c.kustomizeDir, "bases", c.packageName+".clusterserviceversion.yaml")
+	if len(col.ClusterServiceVersions) == 0 && genutil.IsExist(baseCSVPath) {
+		base, err := bases.ClusterServiceVersion{BasePath: baseCSVPath}.GetBase()
+		if err != nil {
+			return fmt.Errorf("error reading CSV base: %v", err)
 		}
+		col.ClusterServiceVersions = append(col.ClusterServiceVersions, *base)
+	} else {
+		c.println("Building a ClusterServiceVersion without an existing base")
 	}
 
 	var opts []gencsv.Option
@@ -209,9 +223,7 @@ func (c packagemanifestsCmd) run() error {
 		}
 	}
 
-	if !c.quiet && !c.stdout {
-		fmt.Println("Package manifests generated successfully in", c.outputDir)
-	}
+	c.println("Package manifests generated successfully in", c.outputDir)
 
 	return nil
 }
diff --git a/internal/generate/collector/manifests.go b/internal/generate/collector/manifests.go
index 5e161a00..0b2b2524 100644
--- a/internal/generate/collector/manifests.go
+++ b/internal/generate/collector/manifests.go
@@ -73,9 +73,8 @@ var (
 	v1alpha3ScorecardCfgGK = scorecardv1alpha3.GroupVersion.WithKind("Configuration").GroupKind()
 )
 
-// UpdateFromDirs adds Roles, ClusterRoles, Deployments, and Custom Resource examples
-// found in deployDir, and CustomResourceDefinitions found in crdsDir,
-// to their respective fields in a Manifests, then filters and deduplicates them.
+// UpdateFromDirs adds CustomResourceDefinitions found in crdsDir, and all other CSV-relevant manifests
+// from deployDir, to their respective fields in a Manifests, then filters and deduplicates them.
 // All other objects are added to Manifests.Others.
 func (c *Manifests) UpdateFromDirs(deployDir, crdsDir string) error {
 	// Collect all manifests in paths.
@@ -88,49 +87,7 @@ func (c *Manifests) UpdateFromDirs(deployDir, crdsDir string) error {
 		if err != nil {
 			return err
 		}
-		scanner := k8sutil.NewYAMLScanner(bytes.NewBuffer(b))
-		for scanner.Scan() {
-			manifest := scanner.Bytes()
-			typeMeta, err := k8sutil.GetTypeMetaFromBytes(manifest)
-			if err != nil {
-				log.Debugf("No TypeMeta in %s, skipping file", path)
-				continue
-			}
-
-			gvk := typeMeta.GroupVersionKind()
-			switch gvk.GroupKind() {
-			case csvGK:
-				err = c.addClusterServiceVersions(manifest)
-			case roleGK:
-				err = c.addRoles(manifest)
-			case clusterRoleGK:
-				err = c.addClusterRoles(manifest)
-			case roleBindingGK:
-				err = c.addRoleBindings(manifest)
-			case clusterRoleBindingGK:
-				err = c.addClusterRoleBindings(manifest)
-			case serviceAccountGK:
-				err = c.addServiceAccounts(manifest)
-			case serviceGK:
-				err = c.addServices(manifest)
-			case deploymentGK:
-				err = c.addDeployments(manifest)
-			case crdGK:
-				// Skip for now and add explicitly from CRDsDir input.
-			case validatingWebhookCfgGK:
-				err = c.addValidatingWebhookConfigurations(manifest)
-			case mutatingWebhookCfgGK:
-				err = c.addMutatingWebhookConfigurations(manifest)
-			case v1alpha3ScorecardCfgGK:
-				err = c.addScorecardConfig(manifest)
-			default:
-				err = c.addOthers(manifest)
-			}
-			if err != nil {
-				return fmt.Errorf("error adding %s to manifest collector: %v", gvk, err)
-			}
-		}
-		return scanner.Err()
+		return c.updateFromReader(bytes.NewBuffer(b))
 	})
 	if err != nil {
 		return fmt.Errorf("error collecting manifests from directory %s: %v", deployDir, err)
@@ -155,11 +112,33 @@ func (c *Manifests) UpdateFromDirs(deployDir, crdsDir string) error {
 	return nil
 }
 
+// UpdateFromDir adds all CSV-relevant manifests from dir to their respective fields in a Manifests,
+// then filters and deduplicates them. All other objects are added to Manifests.Others.
+func (c *Manifests) UpdateFromDir(dir string) error {
+	return c.UpdateFromDirs(dir, "")
+}
+
 // UpdateFromReader adds Roles, ClusterRoles, Deployments, CustomResourceDefinitions,
 // and Custom Resources found in r to their respective fields in a Manifests, then
 // filters and deduplicates them. All other objects are added to Manifests.Others.
 func (c *Manifests) UpdateFromReader(r io.Reader) error {
 	// Bundle contents.
+	if err := c.updateFromReader(r); err != nil {
+		return err
+	}
+
+	// Filter manifests based on data collected.
+	c.filter()
+
+	// Remove duplicate manifests.
+	if err := c.deduplicate(); err != nil {
+		return fmt.Errorf("error removing duplicate manifests: %v", err)
+	}
+
+	return nil
+}
+
+func (c *Manifests) updateFromReader(r io.Reader) error {
 	scanner := k8sutil.NewYAMLScanner(r)
 	for scanner.Scan() {
 		manifest := scanner.Bytes()
@@ -206,14 +185,6 @@ func (c *Manifests) UpdateFromReader(r io.Reader) error {
 		return fmt.Errorf("error collecting manifests from reader: %v", err)
 	}
 
-	// Filter manifests based on data collected.
-	c.filter()
-
-	// Remove duplicate manifests.
-	if err := c.deduplicate(); err != nil {
-		return fmt.Errorf("error removing duplicate manifests: %v", err)
-	}
-
 	return nil
 }
 
diff --git a/internal/olm/operator/registry/configmap/deployment.go b/internal/olm/operator/registry/configmap/deployment.go
index 067169a9..4a4b365a 100644
--- a/internal/olm/operator/registry/configmap/deployment.go
+++ b/internal/olm/operator/registry/configmap/deployment.go
@@ -25,10 +25,10 @@ import (
 )
 
 const (
-	// The image operator-registry's initializer and registry-server binaries
-	// are run from.
-	// QUESTION(estroz): version registry image?
-	registryBaseImage = "quay.io/operator-framework/upstream-registry-builder:latest"
+	// The image operator-registry's initializer and registry-server binaries are run from.
+	// This iamge has been pinned to the latest version containing the initializer and registry-server
+	// binaries, which were deprecated in https://github.com/operator-framework/operator-registry/pull/587.
+	registryBaseImage = "quay.io/operator-framework/upstream-registry-builder:v1.16.0"
 	// The port registry-server will listen on within a container.
 	registryGRPCPort = 50051
 	// Path of the bundle database generated by initializer. Use /tmp since it is
diff --git a/internal/plugins/helm/v1/api.go b/internal/plugins/helm/v1/api.go
index 3463692e..b006de58 100644
--- a/internal/plugins/helm/v1/api.go
+++ b/internal/plugins/helm/v1/api.go
@@ -15,10 +15,12 @@
 package v1
 
 import (
+	"errors"
 	"fmt"
 	"strings"
 
 	"github.com/spf13/pflag"
+	"helm.sh/helm/v3/pkg/chart"
 	"sigs.k8s.io/kubebuilder/v3/pkg/config"
 	"sigs.k8s.io/kubebuilder/v3/pkg/model/resource"
 	"sigs.k8s.io/kubebuilder/v3/pkg/plugin"
@@ -31,8 +33,11 @@ import (
 )
 
 type createAPISubcommand struct {
-	config        config.Config
-	createOptions chartutil.CreateOptions
+	config  config.Config
+	options chartutil.CreateOptions
+
+	resource *resource.Resource
+	chrt     *chart.Chart
 }
 
 var (
@@ -87,32 +92,26 @@ func (p createAPISubcommand) UpdateContext(ctx *plugin.Context) {
 }
 
 const (
-	groupFlag            = "group"
-	versionFlag          = "version"
-	kindFlag             = "kind"
 	helmChartFlag        = "helm-chart"
 	helmChartRepoFlag    = "helm-chart-repo"
 	helmChartVersionFlag = "helm-chart-version"
-	crdVersionFlag       = "crd-version"
 
-	crdVersionV1      = "v1"
-	crdVersionV1beta1 = "v1beta1"
+	defaultCRDVersion = "v1"
 )
 
 // BindFlags will set the flags for the plugin
 func (p *createAPISubcommand) BindFlags(fs *pflag.FlagSet) {
-	p.createOptions = chartutil.CreateOptions{}
 	fs.SortFlags = false
 
-	fs.StringVar(&p.createOptions.GVK.Group, groupFlag, "", "resource group")
-	fs.StringVar(&p.createOptions.GVK.Version, versionFlag, "", "resource version")
-	fs.StringVar(&p.createOptions.GVK.Kind, kindFlag, "", "resource kind")
+	fs.StringVar(&p.options.GVK.Group, "group", "", "resource group")
+	fs.StringVar(&p.options.GVK.Version, "version", "", "resource version")
+	fs.StringVar(&p.options.GVK.Kind, "kind", "", "resource kind")
 
-	fs.StringVar(&p.createOptions.Chart, helmChartFlag, "", "helm chart")
-	fs.StringVar(&p.createOptions.Repo, helmChartRepoFlag, "", "helm chart repository")
-	fs.StringVar(&p.createOptions.Version, helmChartVersionFlag, "", "helm chart version (default: latest)")
+	fs.StringVar(&p.options.Chart, helmChartFlag, "", "helm chart")
+	fs.StringVar(&p.options.Repo, helmChartRepoFlag, "", "helm chart repository")
+	fs.StringVar(&p.options.Version, helmChartVersionFlag, "", "helm chart version (default: latest)")
 
-	fs.StringVar(&p.createOptions.CRDVersion, crdVersionFlag, crdVersionV1, "crd version to generate")
+	fs.StringVar(&p.options.CRDVersion, "crd-version", defaultCRDVersion, "crd version to generate")
 }
 
 // InjectConfig will inject the PROJECT file/config in the plugin
@@ -136,19 +135,19 @@ func (p *createAPISubcommand) Run() error {
 
 // SDK phase 2 plugins.
 func (p *createAPISubcommand) runPhase2() error {
-	ogvk := p.createOptions.GVK
-	gvk := resource.GVK{Group: ogvk.Group, Version: ogvk.Version, Kind: ogvk.Kind}
-
+	if p.resource == nil {
+		return errors.New("resource must not be nil")
+	}
 	// Initially the helm/v1 plugin was written to not create a "plugins" config entry
 	// for any phase 2 plugin because they did not have their own keys. Now there are phase 2
 	// plugin keys, so those plugins should be run if keys exist. Otherwise, enact old behavior.
 
 	if manifestsv2.HasPluginConfig(p.config) {
-		if err := manifestsv2.RunCreateAPI(p.config, gvk); err != nil {
+		if err := manifestsv2.RunCreateAPI(p.config, p.resource.GVK); err != nil {
 			return err
 		}
 	} else {
-		if err := manifests.RunCreateAPI(p.config, gvk); err != nil {
+		if err := manifests.RunCreateAPI(p.config, p.resource.GVK); err != nil {
 			return err
 		}
 	}
@@ -157,36 +156,49 @@ func (p *createAPISubcommand) runPhase2() error {
 }
 
 // Validate perform the required validations for this plugin
-func (p *createAPISubcommand) Validate() error {
-	if p.createOptions.CRDVersion != crdVersionV1 && p.createOptions.CRDVersion != crdVersionV1beta1 {
-		return fmt.Errorf("value of --%s must be either %q or %q", crdVersionFlag, crdVersionV1, crdVersionV1beta1)
-	}
-
-	if len(strings.TrimSpace(p.createOptions.Chart)) == 0 {
-		if len(strings.TrimSpace(p.createOptions.Repo)) != 0 {
+func (p *createAPISubcommand) Validate() (err error) {
+	if len(strings.TrimSpace(p.options.Chart)) == 0 {
+		if len(strings.TrimSpace(p.options.Repo)) != 0 {
 			return fmt.Errorf("value of --%s can only be used with --%s", helmChartRepoFlag, helmChartFlag)
-		} else if len(p.createOptions.Version) != 0 {
+		} else if len(p.options.Version) != 0 {
 			return fmt.Errorf("value of --%s can only be used with --%s", helmChartVersionFlag, helmChartFlag)
 		}
 	}
 
-	if len(strings.TrimSpace(p.createOptions.Chart)) == 0 {
-		if len(strings.TrimSpace(p.createOptions.GVK.Group)) == 0 {
-			return fmt.Errorf("value of --%s must not have empty value", groupFlag)
+	if len(strings.TrimSpace(p.options.Chart)) == 0 {
+		if len(strings.TrimSpace(p.options.GVK.Group)) == 0 {
+			return errors.New("value of --group must not have empty value")
 		}
-		if len(strings.TrimSpace(p.createOptions.GVK.Version)) == 0 {
-			return fmt.Errorf("value of --%s must not have empty value", versionFlag)
+		if len(strings.TrimSpace(p.options.GVK.Version)) == 0 {
+			return errors.New("value of --version must not have empty value")
 		}
-		if len(strings.TrimSpace(p.createOptions.GVK.Kind)) == 0 {
-			return fmt.Errorf("value of --%s must not have empty value", kindFlag)
+		if len(strings.TrimSpace(p.options.GVK.Kind)) == 0 {
+			return errors.New("value of --kind must not have empty value")
 		}
+	}
 
-		// Validate the resource.
-		ogvk := p.createOptions.GVK
-		gvk := resource.GVK{Group: ogvk.Group, Version: ogvk.Version, Kind: ogvk.Kind}
-		if err := gvk.Validate(); err != nil {
-			return err
-		}
+	// Create and validate the resource and chart from CreateOptions.
+	p.resource, p.chrt, err = chartutil.CreateChart(p.config, p.options)
+	if err != nil {
+		return err
+	}
+	if err := p.resource.Validate(); err != nil {
+		return err
+	}
+
+	// Check that resource doesn't exist
+	if p.config.HasResource(p.resource.GVK) {
+		return errors.New("the API resource already exists")
+	}
+
+	// Check that the provided group can be added to the project
+	if !p.config.IsMultiGroup() && p.config.ResourcesLength() != 0 && !p.config.HasGroup(p.resource.GVK.Group) {
+		return errors.New("multiple groups are not allowed by default, to enable multi-group set 'multigroup: true' in your PROJECT file")
+	}
+
+	// Check CRDVersion against all other CRDVersions in p.config for compatibility.
+	if !p.config.IsCRDVersionCompatible(p.resource.API.CRDVersion) {
+		return fmt.Errorf("only one CRD version can be used for all resources, cannot add %q", p.resource.API.CRDVersion)
 	}
 
 	return nil
@@ -194,7 +206,7 @@ func (p *createAPISubcommand) Validate() error {
 
 // GetScaffolder returns cmdutil.Scaffolder which will be executed due the RunOptions interface implementation
 func (p *createAPISubcommand) GetScaffolder() (cmdutil.Scaffolder, error) {
-	return scaffolds.NewAPIScaffolder(p.config, p.createOptions), nil
+	return scaffolds.NewAPIScaffolder(p.config, p.resource, p.chrt), nil
 }
 
 // PostScaffold runs all actions that should be executed after the default plugin scaffold
diff --git a/internal/plugins/helm/v1/chartutil/chart.go b/internal/plugins/helm/v1/chartutil/chart.go
index 671a1cce..c1dc5248 100644
--- a/internal/plugins/helm/v1/chartutil/chart.go
+++ b/internal/plugins/helm/v1/chartutil/chart.go
@@ -32,6 +32,8 @@ import (
 	"helm.sh/helm/v3/pkg/getter"
 	"helm.sh/helm/v3/pkg/repo"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/kubebuilder/v3/pkg/config"
+	"sigs.k8s.io/kubebuilder/v3/pkg/model/resource"
 	"sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang"
 )
 
@@ -71,8 +73,7 @@ type CreateOptions struct {
 	Domain string
 }
 
-// CreateChart scaffolds a new helm chart for the project rooted in projectDir
-// based on the passed opts.
+// CreateChart creates a new helm chart based on the passed opts.
 //
 // It returns a scaffold.Resource that can be used by the caller to create
 // other related files. opts.ResourceAPIVersion and opts.ResourceKind are
@@ -82,17 +83,16 @@ type CreateOptions struct {
 // left unset: opts.ResourceAPIVersion defaults to "charts.helm.k8s.io/v1alpha1"
 // and opts.ResourceKind is deduced from the specified opts.Chart.
 //
-// CreateChart also returns a chart.Chart that references the newly created
-// chart.
+// CreateChart also returns the newly created chart.Chart.
 //
-// If opts.Chart is empty, CreateChart scaffolds the default chart from helm's
+// If opts.Chart is empty, CreateChart creates the default chart from helm's
 // default template.
 //
 // If opts.Chart is a local file, CreateChart verifies that it is a valid helm
-// chart archive and unpacks it into the project's helm charts directory.
+// chart archive and returns its chart.Chart representation.
 //
 // If opts.Chart is a local directory, CreateChart verifies that it is a valid
-// helm chart directory and copies it into the project's helm charts directory.
+// helm chart directory and returns its chart.Chart representation.
 //
 // For any other value of opts.Chart, CreateChart attempts to fetch the helm chart
 // from a remote repository.
@@ -115,36 +115,34 @@ type CreateOptions struct {
 // opts.Version is not used when opts.Chart itself refers to a specific version, for
 // example when it is a local path or a URL.
 //
-// CreateChart returns an error if an error occurs creating the scaffold.Resource or
-// creating the chart.
-func CreateChart(projectDir string, opts CreateOptions) (*golang.Options, *chart.Chart, error) {
-	chartsDir := filepath.Join(projectDir, HelmChartsDir)
-	err := os.MkdirAll(chartsDir, 0755)
+// CreateChart returns an error if an error occurs creating the resource.Resource or
+// loading the chart.Chart.
+func CreateChart(cfg config.Config, opts CreateOptions) (r *resource.Resource, c *chart.Chart, err error) {
+	tmpDir, err := ioutil.TempDir("", "osdk-helm-chart")
 	if err != nil {
-		return nil, nil, fmt.Errorf("failed to create helm-charts directory: %v", err)
+		return nil, nil, err
 	}
-
-	var (
-		r *golang.Options
-		c *chart.Chart
-	)
+	defer func() {
+		if err := os.RemoveAll(tmpDir); err != nil {
+			log.Errorf("Failed to remove temporary chart directory %s: %s", tmpDir, err)
+		}
+	}()
 
 	// If we don't have a helm chart reference, scaffold the default chart
 	// from Helm's default template. Otherwise, fetch it.
 	if len(opts.Chart) == 0 {
-		r, c, err = scaffoldChart(chartsDir, opts)
+		r, c, err = scaffoldChart(cfg, tmpDir, opts)
 		if err != nil {
 			return nil, nil, fmt.Errorf("failed to scaffold default chart: %v", err)
 		}
 	} else {
-		r, c, err = fetchChart(chartsDir, opts)
+		r, c, err = fetchChart(cfg, tmpDir, opts)
 		if err != nil {
 			return nil, nil, fmt.Errorf("failed to fetch chart: %v", err)
 		}
 	}
 
-	relChartPath := filepath.Join(HelmChartsDir, c.Name())
-	absChartPath := filepath.Join(projectDir, relChartPath)
+	absChartPath := filepath.Join(tmpDir, c.Name())
 	if err := fetchChartDependencies(absChartPath); err != nil {
 		return nil, nil, fmt.Errorf("failed to fetch chart dependencies: %v", err)
 	}
@@ -155,36 +153,23 @@ func CreateChart(projectDir string, opts CreateOptions) (*golang.Options, *chart
 		return nil, nil, fmt.Errorf("failed to load chart: %v", err)
 	}
 
-	fmt.Printf("Created %s\n", relChartPath)
 	return r, c, nil
 }
 
-func scaffoldChart(destDir string, opts CreateOptions) (*golang.Options, *chart.Chart, error) {
-	r := &golang.Options{}
-	r.Namespaced = true
-	r.Domain = opts.Domain
-	r.Group = opts.GVK.Group
-	r.Version = opts.GVK.Version
-	r.Kind = opts.GVK.Kind
-
-	chartPath, err := chartutil.Create(strings.ToLower(r.Kind), destDir)
+func scaffoldChart(cfg config.Config, destDir string, opts CreateOptions) (*resource.Resource, *chart.Chart, error) {
+	chartPath, err := chartutil.Create(strings.ToLower(opts.GVK.Kind), destDir)
 	if err != nil {
 		return nil, nil, err
 	}
-
 	chart, err := loader.Load(chartPath)
 	if err != nil {
 		return nil, nil, err
 	}
-	return r, chart, nil
-}
 
-func fetchChart(destDir string, opts CreateOptions) (*golang.Options, *chart.Chart, error) {
-	var (
-		chart *chart.Chart
-		err   error
-	)
+	return opts.NewResource(cfg), chart, nil
+}
 
+func fetchChart(cfg config.Config, destDir string, opts CreateOptions) (_ *resource.Resource, chart *chart.Chart, err error) {
 	if _, err = os.Stat(opts.Chart); err == nil {
 		chart, err = createChartFromDisk(destDir, opts.Chart)
 	} else {
@@ -205,14 +190,24 @@ func fetchChart(destDir string, opts CreateOptions) (*golang.Options, *chart.Cha
 		opts.GVK.Kind = strcase.ToCamel(chartName)
 	}
 
-	r := &golang.Options{}
-	r.Namespaced = true
-	r.Domain = opts.Domain
-	r.Group = opts.GVK.Group
-	r.Version = opts.GVK.Version
-	r.Kind = opts.GVK.Kind
+	return opts.NewResource(cfg), chart, nil
+}
 
-	return r, chart, nil
+func (opts CreateOptions) NewResource(cfg config.Config) *resource.Resource {
+	ro := &golang.Options{}
+	ro.DoAPI = true
+	ro.Namespaced = true
+	ro.Domain = opts.Domain
+	ro.CRDVersion = opts.CRDVersion
+	ro.Group = opts.GVK.Group
+	ro.Version = opts.GVK.Version
+	ro.Kind = opts.GVK.Kind
+
+	r := ro.NewResource(cfg)
+	r.Domain = cfg.GetDomain()
+	// remove the path since is not a Golang project
+	r.Path = ""
+	return &r
 }
 
 func createChartFromDisk(destDir, source string) (*chart.Chart, error) {
@@ -221,7 +216,7 @@ func createChartFromDisk(destDir, source string) (*chart.Chart, error) {
 		return nil, err
 	}
 
-	// Save it into our project's helm-charts directory.
+	// Save it into destDir.
 	if err := chartutil.SaveDir(chart, destDir); err != nil {
 		return nil, err
 	}
@@ -246,17 +241,7 @@ func createChartFromRemote(destDir string, opts CreateOptions) (*chart.Chart, er
 		opts.Chart = chartURL
 	}
 
-	tmpDir, err := ioutil.TempDir("", "osdk-helm-chart")
-	if err != nil {
-		return nil, err
-	}
-	defer func() {
-		if err := os.RemoveAll(tmpDir); err != nil {
-			log.Errorf("Failed to remove temporary directory %s: %s", tmpDir, err)
-		}
-	}()
-
-	chartArchive, _, err := c.DownloadTo(opts.Chart, opts.Version, tmpDir)
+	chartArchive, _, err := c.DownloadTo(opts.Chart, opts.Version, destDir)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/plugins/helm/v1/init.go b/internal/plugins/helm/v1/init.go
index 9b75c74f..5b3570f6 100644
--- a/internal/plugins/helm/v1/init.go
+++ b/internal/plugins/helm/v1/init.go
@@ -24,7 +24,6 @@ import (
 	"k8s.io/apimachinery/pkg/util/validation"
 	"sigs.k8s.io/kubebuilder/v3/pkg/config"
 	"sigs.k8s.io/kubebuilder/v3/pkg/plugin"
-	"sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang"
 
 	"github.com/operator-framework/operator-sdk/internal/kubebuilder/cmdutil"
 	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/chartutil"
@@ -34,14 +33,14 @@ import (
 )
 
 type initSubcommand struct {
-	config    config.Config
-	options   *golang.Options
-	apiPlugin createAPISubcommand
+	config  config.Config
+	apiSubc createAPISubcommand
 
 	// For help text.
 	commandName string
 
 	// Flags
+	domain      string
 	projectName string
 }
 
@@ -119,11 +118,9 @@ Writes the following files:
 
 // BindFlags will set the flags for the plugin
 func (p *initSubcommand) BindFlags(fs *pflag.FlagSet) {
-	fs.SortFlags = false
-	p.options = &golang.Options{}
-	fs.StringVar(&p.options.Domain, "domain", "my.domain", "domain for groups")
+	fs.StringVar(&p.domain, "domain", "my.domain", "domain for groups")
 	fs.StringVar(&p.projectName, "project-name", "", "name of this project, the default being directory name")
-	p.apiPlugin.BindFlags(fs)
+	p.apiSubc.BindFlags(fs)
 }
 
 // InjectConfig will inject the PROJECT file/config in the plugin
@@ -131,11 +128,33 @@ func (p *initSubcommand) InjectConfig(c config.Config) {
 	// v3 project configs get a 'layout' value.
 	_ = c.SetLayout(pluginKey)
 	p.config = c
-	p.apiPlugin.config = p.config
+	p.apiSubc.config = p.config
 }
 
+// CreateOptions with defaults set, for comparison in case GVK/chart inputs were set.
+var emptyCreateOptions = chartutil.CreateOptions{CRDVersion: "v1"}
+
 // Run will call the plugin actions
 func (p *initSubcommand) Run() error {
+	// Set values in the config
+	if err := p.config.SetProjectName(p.projectName); err != nil {
+		return err
+	}
+	if err := p.config.SetDomain(p.domain); err != nil {
+		return err
+	}
+
+	// Check if the project name is a valid k8s namespace (DNS 1123 label).
+	if p.config.GetProjectName() == "" {
+		dir, err := os.Getwd()
+		if err != nil {
+			return fmt.Errorf("error getting current directory: %v", err)
+		}
+		if err = p.config.SetProjectName(strings.ToLower(filepath.Base(dir))); err != nil {
+			return err
+		}
+	}
+
 	if err := cmdutil.Run(p); err != nil {
 		return err
 	}
@@ -145,6 +164,14 @@ func (p *initSubcommand) Run() error {
 		return err
 	}
 
+	// If API creation is configured, run the 'create api' subcommand.
+	if p.apiSubc.options != emptyCreateOptions {
+		p.apiSubc.options.Domain = p.domain
+		if err := p.apiSubc.Run(); err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -157,71 +184,27 @@ func (p *initSubcommand) runPhase2() error {
 		return err
 	}
 
-	if p.options.DoAPI {
-		if err := p.apiPlugin.runPhase2(); err != nil {
-			return err
-		}
-	}
-
 	return nil
 }
 
 // Validate perform the required validations for this plugin
 func (p *initSubcommand) Validate() error {
-	// Set values in the config
-	if err := p.config.SetProjectName(p.projectName); err != nil {
-		return err
-	}
-	if err := p.config.SetDomain(p.options.Domain); err != nil {
-		return err
-	}
-
-	// Check if the project name is a valid k8s namespace (DNS 1123 label).
-	if p.config.GetProjectName() == "" {
-		dir, err := os.Getwd()
-		if err != nil {
-			return fmt.Errorf("error getting current directory: %v", err)
-		}
-		if err = p.config.SetProjectName(strings.ToLower(filepath.Base(dir))); err != nil {
-			return err
-		}
-	}
-
 	if err := validation.IsDNS1123Label(p.config.GetProjectName()); err != nil {
 		return fmt.Errorf("project name (%s) is invalid: %v", p.config.GetProjectName(), err)
 	}
 
-	defaultOpts := chartutil.CreateOptions{CRDVersion: "v1"}
-	if !p.apiPlugin.createOptions.GVK.Empty() || p.apiPlugin.createOptions != defaultOpts {
-		p.options.DoAPI = true
-		return p.apiPlugin.Validate()
-	}
-
 	return nil
 }
 
 // GetScaffolder returns cmdutil.Scaffolder which will be executed due the RunOptions interface implementation
 func (p *initSubcommand) GetScaffolder() (cmdutil.Scaffolder, error) {
-	var (
-		apiScaffolder cmdutil.Scaffolder
-		err           error
-	)
-	if p.options.DoAPI {
-		apiScaffolder, err = p.apiPlugin.GetScaffolder()
-		if err != nil {
-			return nil, err
-		}
-	}
-	return scaffolds.NewInitScaffolder(p.config, apiScaffolder), nil
+	return scaffolds.NewInitScaffolder(p.config), nil
 }
 
 // PostScaffold will run the required actions after the default plugin scaffold
 func (p *initSubcommand) PostScaffold() error {
-
-	if p.options.DoAPI {
-		return p.apiPlugin.PostScaffold()
+	if p.apiSubc.options == emptyCreateOptions {
+		fmt.Printf("Next: define a resource with:\n$ %s create api\n", p.commandName)
 	}
-
-	fmt.Printf("Next: define a resource with:\n$ %s create api\n", p.commandName)
 	return nil
 }
diff --git a/internal/plugins/helm/v1/scaffolds/api.go b/internal/plugins/helm/v1/scaffolds/api.go
index 29c7db31..6323c036 100644
--- a/internal/plugins/helm/v1/scaffolds/api.go
+++ b/internal/plugins/helm/v1/scaffolds/api.go
@@ -23,13 +23,15 @@ import (
 	"os"
 	"path/filepath"
 
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/chartutil"
 	"sigs.k8s.io/kubebuilder/v3/pkg/config"
 	"sigs.k8s.io/kubebuilder/v3/pkg/model"
 	"sigs.k8s.io/kubebuilder/v3/pkg/model/resource"
 
 	"github.com/operator-framework/operator-sdk/internal/kubebuilder/cmdutil"
 	"github.com/operator-framework/operator-sdk/internal/kubebuilder/machinery"
-	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/chartutil"
+	internalchartutil "github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/chartutil"
 	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/scaffolds/internal/templates"
 	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/scaffolds/internal/templates/config/crd"
 	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/scaffolds/internal/templates/config/rbac"
@@ -41,15 +43,17 @@ var _ cmdutil.Scaffolder = &apiScaffolder{}
 // apiScaffolder contains configuration for generating scaffolding for Go type
 // representing the API and controller that implements the behavior for the API.
 type apiScaffolder struct {
-	config config.Config
-	opts   chartutil.CreateOptions
+	config   config.Config
+	resource *resource.Resource
+	chrt     *chart.Chart
 }
 
 // NewAPIScaffolder returns a new Scaffolder for API/controller creation operations
-func NewAPIScaffolder(config config.Config, opts chartutil.CreateOptions) cmdutil.Scaffolder {
+func NewAPIScaffolder(config config.Config, res *resource.Resource, chrt *chart.Chart) cmdutil.Scaffolder {
 	return &apiScaffolder{
-		config: config,
-		opts:   opts,
+		config:   config,
+		resource: res,
+		chrt:     chrt,
 	}
 }
 
@@ -66,59 +70,36 @@ func (s *apiScaffolder) newUniverse(r *resource.Resource) *model.Universe {
 }
 
 func (s *apiScaffolder) scaffold() error {
-	projectDir, err := os.Getwd()
-	if err != nil {
-		return err
-	}
-	resourceOptions, chrt, err := chartutil.CreateChart(projectDir, s.opts)
-	if err != nil {
-		return err
+	if s.resource == nil {
+		return errors.New("resource must not be nil")
 	}
 
-	resourceOptions.DoAPI = true
-	//todo(camilamacedo86): replace the options by kubernetes-sigs/kubebuilder#1974
-	if err := resourceOptions.Validate(); err != nil {
+	if err := s.config.UpdateResource(*s.resource); err != nil {
 		return err
 	}
+	// Path for file builders.
+	chartPath := filepath.Join(internalchartutil.HelmChartsDir, s.chrt.Name())
 
-	// Check that resource doesn't exist
-	if s.config.HasResource(resourceOptions.GVK()) {
-		return errors.New("the API resource already exists")
-	}
-
-	// Check that the provided group can be added to the project
-	if !s.config.IsMultiGroup() && s.config.ResourcesLength() != 0 && !s.config.HasGroup(resourceOptions.Group) {
-		return errors.New("multiple groups are not allowed by default, to enable multi-group set 'multigroup: true' in your PROJECT file")
+	// Write the chart to disk.
+	projectDir, err := os.Getwd()
+	if err != nil {
+		return err
 	}
-
-	resource := resourceOptions.NewResource(s.config)
-
-	resource.Domain = s.config.GetDomain()
-
-	// remove the path since is not a Golang project
-	resource.Path = ""
-
-	// add the resource API info to complain with project-version=3
-	// todo: ensure that this information is properly returned from
-	// resource.newResource in upstream ( see kubernetes-sigs/kubebuilder#1974)
-	// and then, remove it.
-	resource.API.Namespaced = true
-	resource.API.CRDVersion = s.opts.CRDVersion
-
-	if err := s.config.UpdateResource(resource); err != nil {
+	absChartDir := filepath.Join(projectDir, internalchartutil.HelmChartsDir)
+	if err := chartutil.SaveDir(s.chrt, absChartDir); err != nil {
 		return err
 	}
+	fmt.Println("Created", chartPath)
 
-	chartPath := filepath.Join(chartutil.HelmChartsDir, chrt.Metadata.Name)
 	if err := machinery.NewScaffold().Execute(
-		s.newUniverse(&resource),
+		s.newUniverse(s.resource),
 		&templates.WatchesUpdater{ChartPath: chartPath},
-		&crd.CRD{CRDVersion: s.opts.CRDVersion},
+		&crd.CRD{CRDVersion: s.resource.API.CRDVersion},
 		&crd.Kustomization{},
 		&rbac.CRDEditorRole{},
 		&rbac.CRDViewerRole{},
-		&rbac.ManagerRoleUpdater{Chart: chrt},
-		&samples.CRDSample{ChartPath: chartPath, Chart: chrt},
+		&rbac.ManagerRoleUpdater{Chart: s.chrt},
+		&samples.CRDSample{ChartPath: chartPath, Chart: s.chrt},
 	); err != nil {
 		return fmt.Errorf("error scaffolding APIs: %v", err)
 	}
diff --git a/internal/plugins/helm/v1/scaffolds/init.go b/internal/plugins/helm/v1/scaffolds/init.go
index a696f681..9a9906bf 100644
--- a/internal/plugins/helm/v1/scaffolds/init.go
+++ b/internal/plugins/helm/v1/scaffolds/init.go
@@ -47,15 +47,13 @@ var helmOperatorVersion = version.ImageVersion
 var _ cmdutil.Scaffolder = &initScaffolder{}
 
 type initScaffolder struct {
-	config        config.Config
-	apiScaffolder cmdutil.Scaffolder
+	config config.Config
 }
 
 // NewInitScaffolder returns a new Scaffolder for project initialization operations
-func NewInitScaffolder(config config.Config, apiScaffolder cmdutil.Scaffolder) cmdutil.Scaffolder {
+func NewInitScaffolder(config config.Config) cmdutil.Scaffolder {
 	return &initScaffolder{
-		config:        config,
-		apiScaffolder: apiScaffolder,
+		config: config,
 	}
 }
 
@@ -67,13 +65,7 @@ func (s *initScaffolder) newUniverse() *model.Universe {
 
 // Scaffold implements Scaffolder
 func (s *initScaffolder) Scaffold() error {
-	if err := s.scaffold(); err != nil {
-		return err
-	}
-	if s.apiScaffolder != nil {
-		return s.apiScaffolder.Scaffold()
-	}
-	return nil
+	return s.scaffold()
 }
 
 func (s *initScaffolder) scaffold() error {
diff --git a/internal/registry/labels.go b/internal/registry/labels.go
index 06ebbeca..209d69e2 100644
--- a/internal/registry/labels.go
+++ b/internal/registry/labels.go
@@ -27,6 +27,12 @@ import (
 	"sigs.k8s.io/yaml"
 )
 
+type MetadataNotFoundError string
+
+func (e MetadataNotFoundError) Error() string {
+	return fmt.Sprintf("metadata not found in %s", string(e))
+}
+
 // Labels is a set of key:value labels from an operator-registry object.
 type Labels map[string]string
 
@@ -76,7 +82,7 @@ func findBundleMetadata(fs afero.Fs, bundleRoot string) (Labels, string, error)
 	}
 
 	if len(annotations) == 0 {
-		return nil, "", fmt.Errorf("metadata not found in %s", bundleRoot)
+		return nil, "", MetadataNotFoundError(bundleRoot)
 	}
 
 	return annotations, annotationsPath, nil
diff --git a/release/Makefile b/release/Makefile
index 9d085684..5c145610 100644
--- a/release/Makefile
+++ b/release/Makefile
@@ -6,7 +6,8 @@ SHELL = /bin/bash
 
 # Dry run flags.
 ifneq ($(DRY_RUN),)
-SNAPSHOT_FLAGS = --snapshot --skip-publish --rm-dist
+SNAPSHOT_FLAGS = --snapshot --skip-publish --skip-sign --rm-dist
+$(shell touch changelog/generated/$(GIT_VERSION).md)
 endif
 
 # Ensure that this Makefile is run from the project root (always contains the 'cmd/' directory).
@@ -23,6 +24,9 @@ ifeq (,$(GIT_VERSION))
 endif
 	$(SCRIPTS_DIR)/fetch goreleaser 0.147.2
 	GORELEASER_CURRENT_TAG=$(GIT_VERSION) $(TOOLS_DIR)/goreleaser $(SNAPSHOT_FLAGS) --release-notes=changelog/generated/$(GIT_VERSION).md --parallelism 5
+ifneq ($(DRY_RUN),)
+	rm changelog/generated/$(GIT_VERSION).md
+endif
 
 ##@ Pre-Release
 
@@ -54,30 +58,6 @@ endif
 	git tag --sign --message "Operator SDK $(RELEASE_VERSION)" $(RELEASE_VERSION)
 	git verify-tag --verbose $(RELEASE_VERSION)
 
-##@ Image deploy
-
-# Convenience wrappers for pushing all remotely hosted images.
-.PHONY: image-push image-push-multiarch
-IMAGE_TARGET_LIST = operator-sdk helm-operator ansible-operator scorecard-test scorecard-test-kuttl
-image-push: $(foreach i,$(IMAGE_TARGET_LIST),image-push/$(i)) ## Push all images for the host architecture.
-image-push-multiarch: $(foreach i,$(IMAGE_TARGET_LIST),image-push-multiarch/$(i)) ## Push the manifest list for all architectures.
-
-# Push an image for an architecture.
-BUILD_IMAGE_REPO = quay.io/operator-framework
-IMAGE_REPO ?= $(BUILD_IMAGE_REPO)
-GO_ARCH := $(shell go env GOARCH)
-image-push/%: IMAGE_PUSH_TAG = $(IMAGE_REPO)/$*-$(GO_ARCH)
-image-push/%:
-	./hack/image/push-image-tags.sh $(BUILD_IMAGE_REPO)/$*:dev $(IMAGE_PUSH_TAG)
-
-# Push multiarch images.
-ARCHES ?= amd64 arm64 ppc64le s390x
-KUTTL_ARCHES := amd64 arm64 ppc64le
-image-push-multiarch/scorecard-test-kuttl: ARCHES = $(KUTTL_ARCHES)
-image-push-multiarch/%: IMAGE_PUSH_TAG = $(IMAGE_REPO)/$*
-image-push-multiarch/%:
-	./hack/image/push-manifest-list.sh $(IMAGE_PUSH_TAG) $(ARCHES)
-
 .DEFAULT_GOAL := help
 .PHONY: help
 help: ## Show this help screen.
diff --git a/website/content/en/docs/building-operators/ansible/installation.md b/website/content/en/docs/building-operators/ansible/installation.md
index 5b3141b3..7840117f 100644
--- a/website/content/en/docs/building-operators/ansible/installation.md
+++ b/website/content/en/docs/building-operators/ansible/installation.md
@@ -9,16 +9,22 @@ weight: 1
 Follow the steps in the [installation guide][install-guide] to learn how to install the `operator-sdk` CLI tool.
 
 ## Additional Prerequisites
+
+- [docker][docker_tool] version 17.03+.
 - [python][python] version 3.8.6+
 - [ansible][ansible] version v2.9.0+
 - [ansible-runner][ansible-runner] version v1.1.0+
 - [ansible-runner-http][ansible-runner-http-plugin] version v1.0.0+
 - [openshift][openshift-module] version v0.11.2+
+- [kubectl][kubectl_tool] and access to a Kubernetes cluster of a [compatible version][k8s-version-compat].
 
 
+[docker_tool]:https://docs.docker.com/install/
 [install-guide]:/docs/installation/
 [python]:https://www.python.org/downloads/
 [ansible]:https://docs.ansible.com/ansible/latest/index.html
 [ansible-runner]:https://ansible-runner.readthedocs.io/en/latest/install.html
 [ansible-runner-http-plugin]:https://github.com/ansible/ansible-runner-http
 [openshift-module]:https://pypi.org/project/openshift/
+[kubectl_tool]:https://kubernetes.io/docs/tasks/tools/install-kubectl/
+[k8s-version-compat]:/docs/overview#kubernetes-version-compatibility
diff --git a/website/content/en/docs/building-operators/golang/installation.md b/website/content/en/docs/building-operators/golang/installation.md
index 166224f2..12bdd38a 100644
--- a/website/content/en/docs/building-operators/golang/installation.md
+++ b/website/content/en/docs/building-operators/golang/installation.md
@@ -11,16 +11,14 @@ Follow the steps in the [installation guide][install-guide] to learn how to inst
 ## Additional Prerequisites
 
 - [git][git_tool]
-- [go][go_tool] version v1.13+.
-- [mercurial][mercurial_tool] version 3.9+
+- [go][go_tool] version 1.15
 - [docker][docker_tool] version 17.03+.
-- [kubectl][kubectl_tool] version v1.11.3+.
-- Access to a Kubernetes v1.11.3+ cluster (v1.16.0+ if using `apiextensions.k8s.io/v1` CRDs).
+- [kubectl][kubectl_tool] and access to a Kubernetes cluster of a [compatible version][k8s-version-compat].
 
 
 [install-guide]:/docs/installation/
 [git_tool]:https://git-scm.com/downloads
 [go_tool]:https://golang.org/dl/
 [docker_tool]:https://docs.docker.com/install/
-[mercurial_tool]:https://www.mercurial-scm.org/downloads
 [kubectl_tool]:https://kubernetes.io/docs/tasks/tools/install-kubectl/
+[k8s-version-compat]:/docs/overview#kubernetes-version-compatibility
diff --git a/website/content/en/docs/building-operators/golang/migration.md b/website/content/en/docs/building-operators/golang/migration.md
index 8fccae6f..3a70adb2 100644
--- a/website/content/en/docs/building-operators/golang/migration.md
+++ b/website/content/en/docs/building-operators/golang/migration.md
@@ -7,7 +7,7 @@ description: Instructions for migrating a Go-based project built prior to `v1.0.
 
 ## Overview
 
-The motivation for the new layout is to [integrate Kubebuilder and Operator SDK][integration-doc] based projects. This enables Kubebuilder based projects to use the features provided by Operator SDK. For further information behind this motivation, check [the differences between Kubebuilder and Operator-SDK][what-are-the-the-differences-between-kubebuilder-and-operator-sdk]. 
+The motivation for the new layout is to [integrate Kubebuilder and Operator SDK][integration-doc] based projects. This enables Kubebuilder based projects to use the features provided by Operator SDK. For further information behind this motivation, check [the differences between Kubebuilder and Operator-SDK][what-are-the-the-differences-between-kubebuilder-and-operator-sdk].
 
 ### What was changed
 
@@ -30,11 +30,11 @@ Projects are now scaffold using:
 - Helpers and options to work with webhooks. For further information see [What is a Webhook?][webhook-doc]
 - Updated metrics configuration using [kube-auth-proxy][kube-auth-proxy], a `--metrics-addr` flag, and [kustomize][kustomize]-based deployment of a Kubernetes `Service` and prometheus operator `ServiceMonitor`
 - Scaffolded tests that use the [`envtest`][envtest] test framework
-- A preliminary support for plugins. For more info see the [extensible CLI and scaffolding plugins design document][plugins-phase1-design-doc] 
+- A preliminary support for plugins. For more info see the [extensible CLI and scaffolding plugins design document][plugins-phase1-design-doc]
 - A PROJECT file which stores more information about the resources in use, to enable plugins make useful decisions while scaffolding
 - Liveness and Readiness probes using [`healthz.Ping`][healthz-ping].
 - A new option to create projects using ComponentConfig. For more info, see [enhancement proposal][enhancement proposal] and the [Component config tutorial][component-config-tutorial]
-- Go version `1.15` (previously it was `1.13).
+- Go version `1.15` (previously it was `1.13`).
 
 Generated files with the default API versions:
 
@@ -50,7 +50,7 @@ The most straightforward migration path is to:
 1. Create a new project from scratch to let `operator-sdk` scaffold the new project.
 2. Copy your existing code and configuration into the new project structure.
 
-**Note:** It is recommended that you have your project upgraded to the latest SDK release version (0.19.x+) before following the steps in this guide to migrate to new layout. 
+**Note:** It is recommended that you have your project upgraded to the latest SDK release version (0.19.x+) before following the steps in this guide to migrate to new layout.
 
 Please ensure that you have read [Can I customize the projects generated with SDK tool?][faq-custom] in the [FAQ][faq] before continuing further.
 
@@ -105,7 +105,7 @@ operator-sdk create api \
 
 ### How to keep `apiextensions.k8s.io/v1beta1` for CRDs?
 
-From now on, the CRDs that will be created by controller-gen will be using Kubernetes API version `apiextensions.k8s.io/v1` by default, instead of `apiextensions.k8s.io/v1beta1`. 
+From now on, the CRDs that will be created by controller-gen will be using Kubernetes API version `apiextensions.k8s.io/v1` by default, instead of `apiextensions.k8s.io/v1beta1`.
 
 The `apiextensions.k8s.io/v1beta1` was deprecated in Kubernetes `1.16` and will be removed in Kubernetes `1.22`.
 
@@ -173,15 +173,15 @@ operator-sdk create webhook \
     --group=cache \
     --version=<version> \
     --kind=<Kind> \
-    --conversion 
+    --conversion
 ```
 
 After the webhook is generated, you will need to copy the webhook definition and content from your old project to the new one. You can find the respective file in `api/v1/<kind>_webhook.go`.
 
 ### How to keep using `apiextensions.k8s.io/v1beta1` for Webhooks?
 
-Hereafter, the webhooks that are created by SDK will use Kubernetes API version `admissionregistration.k8s.io/v1` by default instead of `admissionregistration.k8s.io/v1beta1` and `cert-manager.io/v1` instead of `cert-manager.io/v1alpha2`. 
-    
+Hereafter, the webhooks that are created by SDK will use Kubernetes API version `admissionregistration.k8s.io/v1` by default instead of `admissionregistration.k8s.io/v1beta1` and `cert-manager.io/v1` instead of `cert-manager.io/v1alpha2`.
+
 Note that `apiextensions/v1beta1` and `admissionregistration.k8s.io/v1beta1` were deprecated in Kubernetes `1.16` and will be removed in Kubernetes `1.22`. If you use `apiextensions/v1` and `admissionregistration.k8s.io/v1`, then you need to use `cert-manager.io/v1` which will be the default API adopted by the SDK CLI.
 
 **NOTE** If you are using the API `cert-manager.io/v1alpha2`, it is not compatible with the latest Kubernetes API version. (`cert-manager.io/v1alpha2` was deprecated in `Cert-Manager 0.14`. For more info, refer to [CertManager v1.0 docs][cert-manager-docs])
@@ -388,7 +388,7 @@ make docker-build IMG=<some-registry>/<project-name>:<tag>
 
 ## Verify the migration
 
-The project can now be built, and the operator can be deployed on cluster by running the command: 
+The project can now be built, and the operator can be deployed on cluster by running the command:
 
 ```sh
 make deploy IMG=<some-registry>/<project-name>:<tag>
@@ -432,4 +432,4 @@ E.g `kubectl logs deployment.apps/memcached-operator-controller-manager -n memca
 [healthz-ping]: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/healthz#CheckHandler
 [controller-runtime]: https://github.com/kubernetes-sigs/controller-runtime/releases
 [component-config-tutorial]: https://github.com/kubernetes-sigs/kubebuilder/blob/master/docs/book/src/component-config-tutorial/tutorial.md
-[plugins-phase1-design-doc]: https://github.com/kubernetes-sigs/kubebuilder/blob/master/designs/extensible-cli-and-scaffolding-plugins-phase-1.md
\ No newline at end of file
+[plugins-phase1-design-doc]: https://github.com/kubernetes-sigs/kubebuilder/blob/master/designs/extensible-cli-and-scaffolding-plugins-phase-1.md
diff --git a/website/content/en/docs/building-operators/golang/tutorial.md b/website/content/en/docs/building-operators/golang/tutorial.md
index 65300627..2bb65936 100644
--- a/website/content/en/docs/building-operators/golang/tutorial.md
+++ b/website/content/en/docs/building-operators/golang/tutorial.md
@@ -11,7 +11,6 @@ please [migrate][migration-guide], or consult the [legacy docs][legacy-quickstar
 ## Prerequisites
 
 - Go through the [installation guide][install-guide].
-- Access to a Kubernetes v1.11.3+ cluster (v1.16.0+ if using `apiextensions.k8s.io/v1` CRDs).
 - User authorized with `cluster-admin` permissions.
 
 ## Overview
diff --git a/website/content/en/docs/building-operators/helm/installation.md b/website/content/en/docs/building-operators/helm/installation.md
index b58c942f..6d161fff 100644
--- a/website/content/en/docs/building-operators/helm/installation.md
+++ b/website/content/en/docs/building-operators/helm/installation.md
@@ -11,8 +11,7 @@ Follow the steps in the [installation guide][install-guide] to learn how to inst
 ### Additional Prerequisites
 
 - [docker][docker_tool] version 17.03+.
-- [kubectl][kubectl_tool] version v1.11.3+.
-- Access to a Kubernetes v1.11.3+ cluster (v1.16.0+ if using `apiextensions.k8s.io/v1` CRDs).
+- [kubectl][kubectl_tool] and access to a Kubernetes cluster of a [compatible version][k8s-version-compat].
 
 
 [install-guide]:/docs/installation/
diff --git a/website/content/en/docs/cli/operator-sdk_bundle_validate.md b/website/content/en/docs/cli/operator-sdk_bundle_validate.md
index 7f0e57b1..54a36326 100644
--- a/website/content/en/docs/cli/operator-sdk_bundle_validate.md
+++ b/website/content/en/docs/cli/operator-sdk_bundle_validate.md
@@ -55,11 +55,11 @@ To list and run optional validators, which are specified by a label selector:
   operatorhub    name=operatorhub           OperatorHub.io metadata validation
                  suite=operatorframework
 
-To validate a bundle with a superset of requirements for Operator Framework:
+To validate a bundle against the entire suite of validators for Operator Framework, in addition to required bundle validators:
 	
   $ operator-sdk bundle validate ./bundle --select-optional suite=operatorframework
 
-To validate a bundle with a superset of requirements for operatorhub.io specifically:
+To validate a bundle against the validator for operatorhub.io specifically, in addition to required bundle validators:
 	
   $ operator-sdk bundle validate ./bundle --select-optional name=operatorhub
 
diff --git a/website/content/en/docs/cli/operator-sdk_generate_bundle.md b/website/content/en/docs/cli/operator-sdk_generate_bundle.md
index 02d4215b..ce99b4fd 100644
--- a/website/content/en/docs/cli/operator-sdk_generate_bundle.md
+++ b/website/content/en/docs/cli/operator-sdk_generate_bundle.md
@@ -8,9 +8,23 @@ Generates bundle data for the operator
 ### Synopsis
 
 
-Running 'generate bundle' is the first step to publishing your operator to a catalog and/or deploying it with OLM.
-This command generates a set of bundle manifests, metadata, and a bundle.Dockerfile for your operator.
-Typically one would run 'generate kustomize manifests' first to (re)generate kustomize bases consumed by this command.
+Running 'generate bundle' is the first step to publishing your operator to a catalog and deploying it with OLM.
+This command both generates and packages files into an on-disk representation of an operator called a bundle.
+A bundle consists of a ClusterServiceVersion (CSV), CustomResourceDefinitions (CRDs),
+manifests not part of the CSV but required by the operator, some metadata (annotations.yaml),
+and a bundle.Dockerfile to build a bundle image.
+
+A CSV manifest is generated by collecting data from the set of manifests passed to this command (see below),
+such as CRDs, RBAC, etc., and applying that data to a "base" CSV manifest. This base CSV can contain metadata,
+added by hand or by the 'generate kustomize manifests' command, and can be passed in like any other manifest
+(see below) or by file at the exact path '&lt;kustomize-dir&gt;/bases/&lt;package-name&gt;.clusterserviceversion.yaml'.
+Be aware that 'generate bundle' idempotently regenerates a bundle, so all non-metadata values in a base
+will be overwritten. If no base was passed in, input manifest data will be applied to an empty CSV.
+
+There are two ways to pass the to-be-bundled set of manifests to this command: stdin via a Unix pipe,
+or in a directory using '--input-dir'. See command help for more information on these modes.
+Passing a directory is useful for running 'generate bundle' outside of a project or within a project
+that does not use kustomize and/or contains cluster-ready manifests on disk.
 
 Set '--version' to supply a semantic version for your bundle if you are creating one
 for the first time or upgrading an existing one.
@@ -30,62 +44,57 @@ operator-sdk generate bundle [flags]
 
 ```
 
-  # Generate bundle files and build your bundle image with these 'make' recipes:
-  $ make bundle
-  $ export USERNAME=<quay-namespace>
-  $ export BUNDLE_IMG=quay.io/$USERNAME/memcached-operator-bundle:v0.0.1
-  $ make bundle-build BUNDLE_IMG=$BUNDLE_IMG
-
-  # The above recipe runs the following commands manually. First it creates bundle
-  # manifests, metadata, and a bundle.Dockerfile:
-  $ make manifests
-  /home/user/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
-  $ operator-sdk generate kustomize manifests
-
-  Display name for the operator (required):
-  > memcached-operator
-  ...
-
+  # If running within a project or in a project that uses kustomize to generate manifests,
+	# make sure a kustomize directory exists that looks like the following 'config/manifests' directory:
   $ tree config/manifests
   config/manifests
   ├── bases
   │   └── memcached-operator.clusterserviceversion.yaml
   └── kustomization.yaml
-  $ kustomize build config/manifests | operator-sdk generate bundle --overwrite --version 0.0.1
-  Generating bundle manifest version 0.0.1
+
+  # Generate a 0.0.1 bundle by passing manifests to stdin:
+  $ kustomize build config/manifests | operator-sdk generate bundle --version 0.0.1
+  Generating bundle version 0.0.1
   ...
 
-  # After running the above commands, you should see this directory structure:
-  $ tree bundle
-  bundle
+  # If running outside of a project or in a project that does not use kustomize to generate manifests,
+	# make sure cluster-ready manifests are available on disk:
+  $ tree deploy/
+  deploy/
+  ├── crds
+  │   └── cache.my.domain_memcacheds.yaml
+  ├── deployment.yaml
+  ├── role.yaml
+  ├── role_binding.yaml
+  ├── service_account.yaml
+  └── webhooks.yaml
+
+  # Generate a 0.0.1 bundle by passing manifests by dir:
+  $ operator-sdk generate bundle --input-dir deploy --version 0.0.1
+  Generating bundle version 0.0.1
+  ...
+
+  # After running in either of the above modes, you should see this directory structure:
+  $ tree bundle/
+  bundle/
   ├── manifests
   │   ├── cache.my.domain_memcacheds.yaml
   │   └── memcached-operator.clusterserviceversion.yaml
   └── metadata
       └── annotations.yaml
 
-  # Then it validates your bundle files and builds your bundle image:
-  $ operator-sdk bundle validate ./bundle
-  $ docker build -f bundle.Dockerfile -t $BUNDLE_IMG .
-  Sending build context to Docker daemon  42.33MB
-  Step 1/9 : FROM scratch
-  ...
-
-  # You can then push your bundle image:
-  $ make docker-push IMG=$BUNDLE_IMG
-
 ```
 
 ### Options
 
 ```
       --channels string          A comma-separated list of channels the bundle belongs to (default "alpha")
-      --crds-dir string          Root directory for CustomResoureDefinition manifests
+      --crds-dir string          Directory to read cluster-ready CustomResoureDefinition manifests from. This option can only be used if --deploy-dir is set
       --default-channel string   The default channel for the bundle
-      --deploy-dir string        Root directory for operator manifests such as Deployments and RBAC, ex. 'deploy'. This directory is different from that passed to --input-dir
+      --deploy-dir string        Directory to read cluster-ready operator manifests from. If --crds-dir is not set, CRDs are ready from this directory. This option is mutually exclusive with --input-dir and piping to stdin
   -h, --help                     help for bundle
-      --input-dir string         Directory to read an existing bundle from. This directory is the parent of your bundle 'manifests' directory, and different from --deploy-dir
-      --kustomize-dir string     Directory containing kustomize bases and a kustomization.yaml for operator-framework manifests (default "config/manifests")
+      --input-dir string         Directory to read cluster-ready operator manifests from. This option is mutually exclusive with --deploy-dir/--crds-dir and piping to stdin. This option should not be passed an existing bundle directory, as this bundle will not contain the correct set of manifests required to generate a CSV. Use --kustomize-dir to pass a base CSV
+      --kustomize-dir string     Directory containing kustomize bases in a "bases" dir and a kustomization.yaml for operator-framework manifests (default "config/manifests")
       --manifests                Generate bundle manifests
       --metadata                 Generate bundle metadata and Dockerfile
       --output-dir string        Directory to write the bundle to
diff --git a/website/content/en/docs/cli/operator-sdk_generate_packagemanifests.md b/website/content/en/docs/cli/operator-sdk_generate_packagemanifests.md
index 5db37486..df42ad89 100644
--- a/website/content/en/docs/cli/operator-sdk_generate_packagemanifests.md
+++ b/website/content/en/docs/cli/operator-sdk_generate_packagemanifests.md
@@ -11,10 +11,22 @@ Generates package manifests data for the operator
 Note: while the package manifests format is not yet deprecated, the operator-framework is migrated
 towards using bundles by default. Run 'operator-sdk generate bundle -h' for more information.
 
-Running 'generate packagemanifests' is the first step to publishing your operator to a catalog and/or deploying
-it with OLM. This command generates a set of manifests in a versioned directory and a package manifest file for
-your operator. Typically one would run 'generate kustomize manifests' first to (re)generate kustomize bases
-consumed by this command.
+This command generates a set of manifests in a versioned directory and a package manifest file for
+your operator. Each versioned directory consists of a ClusterServiceVersion (CSV), CustomResourceDefinitions (CRDs),
+and manifests not part of the CSV but required by the operator.
+
+A CSV manifest is generated by collecting data from the set of manifests passed to this command (see below),
+such as CRDs, RBAC, etc., and applying that data to a "base" CSV manifest. This base CSV can contain metadata,
+added by hand or by the 'generate kustomize manifests' command, and can be passed in like any other manifest
+(see below) or by file at the exact path '&lt;kustomize-dir&gt;/bases/&lt;package-name&gt;.clusterserviceversion.yaml'.
+Be aware that 'generate packagemanifests' idempotently regenerates a packagemanifests directory,
+so all non-metadata values in a base will be overwritten. If no base was passed in, input manifest data
+will be applied to an empty CSV.
+
+There are two ways to pass the to-be-packaged set of manifests to this command: stdin via a Unix pipe,
+or in a directory using '--input-dir'. See command help for more information on these modes.
+Passing a directory is useful for running 'generate packagemanifests' outside of a project or within a project
+that does not use kustomize and/or contains cluster-ready manifests on disk.
 
 Set '--version' to supply a semantic version for your new package.
 
@@ -30,27 +42,37 @@ operator-sdk generate packagemanifests [flags]
 
 ```
 
-  # Generate manifests then create the package manifests base:
-  $ make manifests
-  /home/user/go/bin/controller-gen "crd:trivialVersions=true" rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
-  $ operator-sdk generate kustomize manifests
-
-  Display name for the operator (required):
-  > memcached-operator
-  ...
-
+  # If running within a project, make sure a 'config' kustomize directory exists and has a 'config/manifests':
   $ tree config/manifests
   config/manifests
   ├── bases
   │   └── memcached-operator.clusterserviceversion.yaml
   └── kustomization.yaml
+
+  # Generate a 0.0.1 packagemanifests by passing manifests to stdin:
   $ kustomize build config/manifests | operator-sdk generate packagemanifests --version 0.0.1
   Generating package manifests version 0.0.1
   ...
 
-  # After running the above commands, you should see this directory structure:
-  $ tree packagemanifests
-  packagemanifests
+  # If running outside of a project, make sure cluster-ready manifests are available on disk:
+  $ tree deploy/
+  deploy/
+  ├── crds
+  │   └── cache.my.domain_memcacheds.yaml
+  ├── deployment.yaml
+  ├── role.yaml
+  ├── role_binding.yaml
+  ├── service_account.yaml
+  └── webhooks.yaml
+
+  # Generate a 0.0.1 packagemanifests by passing manifests by dir:
+  $ operator-sdk generate packagemanifests --deploy-dir deploy --version 0.0.1
+  Generating package manifests version 0.0.1
+  ...
+
+  # After running in either of the above modes, you should see this directory structure:
+  $ tree packagemanifests/
+  packagemanifests/
   ├── 0.0.1
   │   ├── cache.my.domain_memcacheds.yaml
   │   └── memcached-operator.clusterserviceversion.yaml
@@ -62,13 +84,13 @@ operator-sdk generate packagemanifests [flags]
 
 ```
       --channel string         Channel name for the generated package
-      --crds-dir string        Root directory for CustomResoureDefinition manifests
+      --crds-dir string        Directory to read cluster-ready CustomResoureDefinition manifests from. This option can only be used if --deploy-dir is set
       --default-channel        Use the channel passed to --channel as the package manifest file's default channel
-      --deploy-dir string      Root directory for operator manifests such as Deployments and RBAC, ex. 'deploy'. This directory is different from that passed to --input-dir
+      --deploy-dir string      Directory to read cluster-ready operator manifests from. If --crds-dir is not set, CRDs are ready from this directory
       --from-version string    Semantic version of the operator being upgraded from
   -h, --help                   help for packagemanifests
-      --input-dir string       Directory to read existing package manifests from. This directory is the parent of individual versioned package directories, and different from --deploy-dir
-      --kustomize-dir string   Directory containing kustomize bases and a kustomization.yaml for operator-framework manifests (default "config/manifests")
+      --input-dir string       Directory to read existing package manifests from. This directory is the parent of individual versioned package directories, and different from --deploy-dir (default "packagemanifests")
+      --kustomize-dir string   Directory containing kustomize bases in a "bases" dir and a kustomization.yaml for operator-framework manifests (default "config/manifests")
       --output-dir string      Directory in which to write package manifests
       --package string         Package name
   -q, --quiet                  Run in quiet mode
diff --git a/website/content/en/docs/contribution-guidelines/developer-guide.md b/website/content/en/docs/contribution-guidelines/developer-guide.md
index fa07d2d4..653f9dac 100644
--- a/website/content/en/docs/contribution-guidelines/developer-guide.md
+++ b/website/content/en/docs/contribution-guidelines/developer-guide.md
@@ -9,7 +9,7 @@ weight: 1
 ### Prerequisites
 
 - [git][git-tool]
-- [go][go-tool] v1.15+
+- [go][go-tool] version 1.15
 
 ### Download Operator SDK
 
@@ -33,27 +33,20 @@ See the [release guide][dev-release] for more information.
 
 ## Continuous Integration (CI)
 
-The operator-sdk repo uses [Travis CI][travis] to test each pull request and build images for both master commits
-and releases. You can alter these processes by modifying this [`.travis.yml`][travis.yml] config file.
+The operator-sdk repo uses [Github Actions][sdk-actions] to test each pull request and build images for both master commits
+and release tags. You can alter these processes by modifying the appropriate [Action config][sdk-action-cfgs].
 
-### Testing builds with new architectures
+### Adding new architectures
 
-Follow these steps to execute the Travis `Deploy` stage against your branch
-to demonstrate that the merge build will complete as expected.
+The operator-sdk project builds binaries for [several os's/architectures][readme-platforms].
+If you wish to add support for a new one, please create a feature request issue before
+implementing support for that platform and submitting a PR.
+
+<!-- TODO(estroz): add link to deploy action once PR is merged -->
+If you'd like to implement support yourself, you can test a new architecture by enabling Actions
+in your repository, add a platform pair to the `deploy` Action's `build and push` step,
+and push to your main branch. Once the updated Action passes, submit a PR linking the passing Action run.
 
-- Enable Travis in your fork repository. See [this guide][travis-setup] for more information.
-- Create public image repos for each image built by `make image-build`; make sure the registry used supports
-multi-arch images, like quay.io.
-  - For each image type, you need one repo for the manifest list and one for each architecture being tested.
-- Set each image variable (that ends in `_IMAGE`, not `_BASE_IMAGE`) found in the Makefile
-as an environment variable in `.travis.yml`, ex. `export SCORECARD_TEST_IMAGE=<registry>/<username>/scorecard-test:latest`
-- Create a PR with your configuration changes to _your_ fork, with the first commit message containing
-`[travis deploy]`.
-  - This commit is only for testing on your fork's PR. This commit/message should not be present in an operator-sdk
-  repo PR.
-- Ensure the image builds for that PR pass.
-- Create a PR to the operator-sdk repo with a description containing a link to the Travis build page
-showing a successful deployment stage with your changes.
 
 [git-tool]:https://git-scm.com/downloads
 [go-tool]:https://golang.org/dl/
@@ -62,6 +55,6 @@ showing a successful deployment stage with your changes.
 [dev-testing]: /docs/contribution-guidelines/testing
 [dev-docs]: /docs/contribution-guidelines/documentation
 [dev-release]: /docs/contribution-guidelines/releasing
-[travis]: https://docs.travis-ci.com/
-[travis.yml]: https://github.com/operator-framework/operator-sdk/blob/master/.travis.yml
-[travis-setup]: https://docs.travis-ci.com/user/tutorial/#to-get-started-with-travis-ci-using-github
+[sdk-actions]:https://github.com/operator-framework/operator-sdk/actions
+[sdk-action-cfgs]:https://github.com/operator-framework/operator-sdk/tree/master/.github/workflows
+[readme-platforms]:https://github.com/operator-framework/operator-sdk/tree/master/README.md#platforms
diff --git a/website/content/en/docs/installation/_index.md b/website/content/en/docs/installation/_index.md
index b2e9a6e4..f78d846a 100644
--- a/website/content/en/docs/installation/_index.md
+++ b/website/content/en/docs/installation/_index.md
@@ -88,9 +88,8 @@ chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/l
 #### Prerequisites
 
 - [git][git_tool]
-- [mercurial][mercurial_tool] version 3.9+
-- [bazaar][bazaar_tool] version 2.7.0+
-- [go][go_tool] version v1.15+.
+- [go][go_tool] version 1.15
+  - Ensure that your `GOPROXY` is set to `"https://proxy.golang.org|direct"`
 
 ```sh
 git clone https://github.com/operator-framework/operator-sdk
@@ -99,11 +98,6 @@ git checkout master
 make install
 ```
 
-**Note:** Ensure that your `GOPROXY` is set with its default value for Go
-versions 1.15+ which is `"https://proxy.golang.org|direct"`.
-
 [homebrew_tool]:https://brew.sh/
 [git_tool]:https://git-scm.com/downloads
-[mercurial_tool]:https://www.mercurial-scm.org/downloads
-[bazaar_tool]:http://wiki.bazaar.canonical.com/Download
 [go_tool]:https://golang.org/dl/
diff --git a/website/content/en/docs/olm-integration/cli-overview.md b/website/content/en/docs/olm-integration/cli-overview.md
index 77366a2e..c7842d1a 100644
--- a/website/content/en/docs/olm-integration/cli-overview.md
+++ b/website/content/en/docs/olm-integration/cli-overview.md
@@ -36,7 +36,7 @@ The following `make` recipes and `operator-sdk` subcommands create or interact w
 - `make bundle-build`: builds a bundle image using the `bundle.Dockerfile` generated by `make bundle`.
 - [`run bundle`][cli-run-bundle]: runs the given Operator's bundle image with an
   existing OLM installation.
-- [`run bundle-upgrade`][cli-run-bundle-upgrade]: upgrades the Operator bundle version.
+- [`run bundle-upgrade`][cli-run-bundle-upgrade]: upgrades the Operator bundle to a specified newer version.
 
 ##### Package Manifests
 
diff --git a/website/content/en/docs/olm-integration/quickstart-bundle.md b/website/content/en/docs/olm-integration/quickstart-bundle.md
index d0d37046..9332429d 100644
--- a/website/content/en/docs/olm-integration/quickstart-bundle.md
+++ b/website/content/en/docs/olm-integration/quickstart-bundle.md
@@ -143,7 +143,7 @@ INFO[0040] OLM has successfully installed "memcached-operator.v0.0.1"
 
 <!-- TODO(jmccormick2001): add `scorecard` usage here -->
 
-### Upgrading bundles to a newer version
+### Upgrading a bundle to a newer version
 
 We can use the `operator-sdk run bundle-upgrade` command with a newer version of bundle image to upgrade 
 an existing operator bundle deployed on cluster. The command automates the manual orchestration typically required to upgrade an operator 
@@ -169,6 +169,68 @@ INFO[0095]   Found ClusterServiceVersion "default/memcached-operator.v0.0.2" pha
 INFO[0095] Successfully upgraded to "memcached-operator.v0.0.2"
 ```
 
+#### Upgrading a bundle that was installed traditionally using OLM
+
+An operator bundle can be upgraded even if it was originally deployed using OLM without using the `run bundle` command.
+
+Let's see how to deploy an operator bundle traditionally using OLM and then upgrade the operator bundle to a newer version.
+
+First, create a catalogsource by building the catalogsource from an index.
+```
+$ oc create -f catalogsource.yaml
+```
+```
+apiVersion: operators.coreos.com/v1alpha1
+kind: CatalogSource
+metadata:
+  name: etcdoperator
+  namespace: default
+spec:
+  displayName: Etcd Operators
+  image: <some-registry>/etcd-index:latest
+  sourceType: grpc
+```
+
+Next, install the operator bundle by creating a subscription.
+```
+$ oc create -f subscription.yaml
+```
+
+```
+apiVersion: v1
+items:
+- apiVersion: operators.coreos.com/v1alpha1
+  kind: Subscription
+  metadata:
+    name: etcd
+    namespace: default
+  spec:
+    channel: "stable"
+    installPlanApproval: Manual
+    name: etcd
+    source: etcdoperator
+    sourceNamespace: default
+    startingCSV: etcdoperator.v0.0.1
+```
+
+Once the Operator bundle is deployed, you can use the `run bundle-upgrade` command by specifing the new bundle image that you want to upgrade to.
+
+```console
+$ operator-sdk run bundle-upgrade <some-registry>/etcd-bundle:v0.0.2
+INFO[0000] Found existing subscription with name etcd and namespace default
+INFO[0000] Found existing catalog source with name etcdoperator and namespace default
+INFO[0005] Successfully created registry pod: <some-registry>-etcd-bundle-0-0-2
+INFO[0005] Updated catalog source etcdoperator with address and annotations
+INFO[0005] Deleted previous registry pod with name "<some-registry>-etcd-bundle-0-0-1"
+INFO[0005] Approved InstallPlan install-6vrzh for the Subscription: etcd
+INFO[0005] Waiting for ClusterServiceVersion "default/etcdoperator.v0.0.2" to reach 'Succeeded' phase
+INFO[0005]   Waiting for ClusterServiceVersion "default/etcdoperator.v0.0.2" to appear
+INFO[0007]   Found ClusterServiceVersion "default/etcdoperator.v0.0.2" phase: Pending
+INFO[0008]   Found ClusterServiceVersion "default/etcdoperator.v0.0.2" phase: Installing
+INFO[0018]   Found ClusterServiceVersion "default/etcdoperator.v0.0.2" phase: Succeeded
+INFO[0018] Successfully upgraded to "etcdoperator.v0.0.2"
+```
+
 ### Deploying bundles in production
 
 OLM and Operator Registry consumes Operator bundles via an [index image][index-image],
diff --git a/website/content/en/docs/overview/_index.md b/website/content/en/docs/overview/_index.md
index 7845ed50..9a9eeace 100644
--- a/website/content/en/docs/overview/_index.md
+++ b/website/content/en/docs/overview/_index.md
@@ -56,6 +56,60 @@ Note that each operator type has a different set of capabilities. When choosing
 
 Find more details about the various levels and the feature requirements for them in the [capability level documentation][capability_levels].
 
+## Kubernetes version compatibility
+
+Each `operator-sdk` release is tested with a specific version of Kubernetes. This version matches
+that of [kubernetes/kubernetes][k-k] or [client-go][client-go] that `operator-sdk` depends on directly,
+or that generated Operator projects depend on.
+
+In general, client-go's [compatibility matrix][client-go-compat] will determine whether
+a particular Kubernetes version is compatible with a particular `operator-sdk` version
+or generated Operator project. The following tables contains the canonical way per
+binary or project type to look up a Y-axis version to plug into the compatibility matrix.
+
+By binary:
+
+| Binary                  | Lookup strategy               |
+|-------------------------|-------------------------------|
+| `operator-sdk`          | `$ operator-sdk version`      |
+| `ansible-operator`      | `$ ansible-operator version`  |
+| `helm-operator`         | `$ helm-operator version`     |
+
+By project type (replace `${IMAGE_VERSION}` with base image version in your project `Dockerfile`):
+
+| Project type   | Lookup strategy                           |
+|----------------|-------------------------------------------|
+| Go             | controller-runtime version (see `go.mod`) |
+| Ansible        | `$ docker run --entrypoint ansible-operator quay.io/operator-framework/ansible-operator:${IMAGE_VERSION} version` |
+| Helm           | `$ docker run --entrypoint helm-operator quay.io/operator-framework/helm-operator:${IMAGE_VERSION} version` |
+
+
+[k-k]:https://github.com/kubernetes/kubernetes
+[client-go]:https://github.com/kubernetes/client-go
+[client-go-compat]:https://github.com/kubernetes/client-go#compatibility-matrix
+
+## Platform support
+
+Official build architectures for binaries:
+
+| Binary                    | `linux/amd64` | `linux/arm64` |`linux/ppc64le` | `linux/s390x` | `darwin/amd64` | `darwin/arm64` |
+|---------------------------|---------------|---------------|----------------|---------------|----------------|----------------|
+| `operator-sdk`            | ✓             | ✓             | ✓              | ✓             | ✓              | -              |
+| `ansible-operator`        | ✓             | ✓             | ✓              | ✓             | ✓              | -              |
+| `helm-operator`           | ✓             | ✓             | ✓              | ✓             | ✓              | -              |
+
+Official build architectures for images:
+
+| Binary                    | `linux/amd64` | `linux/arm64` |`linux/ppc64le` | `linux/s390x` |
+|---------------------------|---------------|---------------|----------------|---------------|
+| `operator-sdk`            | ✓             | ✓             | ✓              | ✓             |
+| `ansible-operator`        | ✓             | ✓             | ✓              | ✓             |
+| `helm-operator`           | ✓             | ✓             | ✓              | ✓             |
+| `scorecard-test`          | ✓             | ✓             | ✓              | ✓             |
+| `scorecard-test-kuttl`    | ✓             | ✓             | ✓              | -             |
+
+Official support for any Windows architecture is not on the roadmap at this time.
+
 ## Samples
 
 To explore any operator samples built using the operator-sdk, see the samples in [operator-sdk/testdata/][testdata_samples].


Test patch:
diff --git a/hack/tests/e2e-ansible-molecule.sh b/hack/tests/e2e-ansible-molecule.sh
index 087db61f..f968dd9c 100755
--- a/hack/tests/e2e-ansible-molecule.sh
+++ b/hack/tests/e2e-ansible-molecule.sh
@@ -1,31 +1,45 @@
 #!/usr/bin/env bash
 
 source hack/lib/common.sh
-source hack/lib/image_lib.sh
+
+# load_image_if_kind <image tag>
+#
+# load_image_if_kind loads an image into all nodes in a kind cluster.
+#
+function load_image_if_kind() {
+  local cluster=${KIND_CLUSTER:-kind}
+  if [[ "$(kubectl config current-context)" == "kind-${cluster}" ]]; then
+    kind load docker-image --name "${cluster}" "$1"
+  fi
+}
 
 set -eu
 
-header_text "Running tests to check ansible molecule"
+header_text "Running ansible molecule tests in a python3 virtual environment"
 
-ROOTDIR="$(pwd)"
+# Set up a python3.8 virtual environment.
+ENVDIR="$(mktemp -d)"
+trap_add "set +u; deactivate; set -u; rm -rf $ENVDIR" EXIT
+python3 -m venv "$ENVDIR"
+set +u; source "${ENVDIR}/bin/activate"; set -u
+
+# Install dependencies.
 TMPDIR="$(mktemp -d)"
-trap_add 'rm -rf $TMPDIR' EXIT
-export PATH=${HOME}/.local/bin:${PATH}
-pip3 install --user pyasn1==0.4.7 pyasn1-modules==0.2.6 idna==2.8 ipaddress==1.0.22
-pip3 install --user molecule==3.0.2
-pip3 install --user ansible-lint yamllint
-pip3 install --user docker==4.2.2 openshift jmespath
+trap_add "rm -rf $TMPDIR" EXIT
+pip3 install pyasn1==0.4.7 pyasn1-modules==0.2.6 idna==2.8 ipaddress==1.0.23
+pip3 install cryptography==3.3.2 molecule==3.0.2
+pip3 install ansible-lint yamllint
+pip3 install docker==4.2.2 openshift==0.11.2 jmespath
 ansible-galaxy collection install 'community.kubernetes:<1.0.0'
 
 header_text "Copying molecule testdata scenarios"
+ROOTDIR="$(pwd)"
 cp -r $ROOTDIR/testdata/ansible/memcached-molecule-operator/ $TMPDIR/memcached-molecule-operator
 cp -r $ROOTDIR/testdata/ansible/advanced-molecule-operator/ $TMPDIR/advanced-molecule-operator
 
-pushd "$TMPDIR"
-popd
-cd $TMPDIR/memcached-molecule-operator
+pushd $TMPDIR/memcached-molecule-operator
 
-header_text "Running Kind test with memcached-molecule-operator/"
+header_text "Running Kind test with memcached-molecule-operator"
 make kustomize
 if [ -f ./bin/kustomize ] ; then
   KUSTOMIZE="$(realpath ./bin/kustomize)"
@@ -35,8 +49,8 @@ fi
 KUSTOMIZE_PATH=${KUSTOMIZE} TEST_OPERATOR_NAMESPACE=default molecule test -s kind
 
 
-header_text "Running Default test with advanced-molecule-operator/"
-cd $TMPDIR/advanced-molecule-operator
+header_text "Running Default test with advanced-molecule-operator"
+pushd $TMPDIR/advanced-molecule-operator
 
 make kustomize
 if [ -f ./bin/kustomize ] ; then
diff --git a/images/custom-scorecard-tests/Dockerfile b/images/custom-scorecard-tests/Dockerfile
index e5dcabaf..07f1a411 100644
--- a/images/custom-scorecard-tests/Dockerfile
+++ b/images/custom-scorecard-tests/Dockerfile
@@ -1,3 +1,22 @@
+# Build the custom-scorecard-tests binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/custom-scorecard-tests
+
+# Final image.
 FROM registry.access.redhat.com/ubi8/ubi-minimal:8.3-230
 
 ENV HOME=/opt/custom-scorecard-tests \
@@ -8,8 +27,7 @@ RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin"
 
 WORKDIR ${HOME}
 
-ARG BIN=bin/custom-scorecard-tests
-COPY $BIN /usr/local/bin/custom-scorecard-tests
+COPY --from=builder /workspace/build/custom-scorecard-tests /usr/local/bin/custom-scorecard-tests
 
 ENTRYPOINT ["/usr/local/bin/custom-scorecard-tests"]
 
diff --git a/images/scorecard-test-kuttl/Dockerfile b/images/scorecard-test-kuttl/Dockerfile
index acbf232f..a382d811 100644
--- a/images/scorecard-test-kuttl/Dockerfile
+++ b/images/scorecard-test-kuttl/Dockerfile
@@ -1,4 +1,22 @@
-#FROM docker.io/kudobuilder/kuttl@sha256:c9c5edc27ba6e5e994ffd8cea03368c0d4e274f3c7a00f51c1e360feb573f24e
+# Build the scorecard-test-kuttl binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/scorecard-test-kuttl
+
+# Final image.
 FROM kudobuilder/kuttl:v0.8.0
 
 ENV HOME=/opt/scorecard-test-kuttl \
@@ -10,9 +28,8 @@ RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin"
 
 WORKDIR ${HOME}
 
-ARG BIN=bin/scorecard-test-kuttl
-COPY $BIN /usr/local/bin/scorecard-test-kuttl
-COPY entrypoint /usr/local/bin/entrypoint
+COPY --from=builder /workspace/build/scorecard-test-kuttl /usr/local/bin/scorecard-test-kuttl
+COPY --from=builder /workspace/images/scorecard-test-kuttl/entrypoint /usr/local/bin/entrypoint
 
 ENTRYPOINT ["/usr/local/bin/entrypoint"]
 
diff --git a/images/scorecard-test/Dockerfile b/images/scorecard-test/Dockerfile
index cdac4696..996970c5 100644
--- a/images/scorecard-test/Dockerfile
+++ b/images/scorecard-test/Dockerfile
@@ -1,3 +1,22 @@
+# Build the scorecard-test binary
+FROM --platform=$BUILDPLATFORM golang:1.15 as builder
+ARG TARGETARCH
+
+WORKDIR /workspace
+# Copy the Go Modules manifests
+COPY go.mod go.mod
+COPY go.sum go.sum
+# cache deps before building and copying source so that we don't need to re-download as much
+# and so that source changes don't invalidate our downloaded layer
+RUN go mod download
+
+# Copy the go source
+COPY . .
+
+# Build
+RUN GOOS=linux GOARCH=$TARGETARCH make build/scorecard-test
+
+# Final image.
 FROM registry.access.redhat.com/ubi8/ubi-minimal:8.3-230
 
 ENV HOME=/opt/scorecard-test \
@@ -8,8 +27,7 @@ RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin"
 
 WORKDIR ${HOME}
 
-ARG BIN=bin/scorecard-test
-COPY $BIN /usr/local/bin/scorecard-test
+COPY --from=builder /workspace/build/scorecard-test /usr/local/bin/scorecard-test
 
 ENTRYPOINT ["/usr/local/bin/scorecard-test"]
 
diff --git a/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests_test.go b/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests_test.go
index 78bab8d9..afbc99f9 100644
--- a/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests_test.go
+++ b/internal/cmd/operator-sdk/generate/packagemanifests/packagemanifests_test.go
@@ -76,14 +76,6 @@ var _ = Describe("Running a generate packagemanifests command", func() {
 			Expect(err).To(HaveOccurred())
 			Expect(err.Error()).To(ContainSubstring("input-dir must be set"))
 		})
-		It("fails if a kuztomize-dir is not provided", func() {
-			c.version = versionOne
-			c.inputDir = inputDir
-
-			err := c.validate()
-			Expect(err).To(HaveOccurred())
-			Expect(err.Error()).To(ContainSubstring("kustomize-dir must be set"))
-		})
 		It("fails if a deploy-dir is not provided while not reading from stdin", func() {
 			c.version = versionOne
 			c.inputDir = inputDir
@@ -177,18 +169,17 @@ var _ = Describe("Running a generate packagemanifests command", func() {
 
 				err := c.setDefaults()
 				Expect(err).NotTo(HaveOccurred())
-				Expect(c.inputDir).To(Equal(defaultRootDir))
+				Expect(c.inputDir).To(Equal(""))
 				Expect(c.outputDir).To(Equal(defaultRootDir))
 				Expect(c.generator).ToNot(BeNil())
 				Expect(c.layout).To(Equal("unknown"))
 			})
-			It("does not set output if stdout has been set", func() {
+			It("does not set outputDir if stdout has been set", func() {
 				c.packageName = "banana"
 				c.stdout = true
 
 				err := c.setDefaults()
 				Expect(err).NotTo(HaveOccurred())
-				Expect(c.inputDir).To(Equal(defaultRootDir))
 				Expect(c.outputDir).To(Equal(""))
 			})
 		})
diff --git a/internal/plugins/helm/v1/chartutil/chart_test.go b/internal/plugins/helm/v1/chartutil/chart_test.go
index bd12af23..e848b6e8 100644
--- a/internal/plugins/helm/v1/chartutil/chart_test.go
+++ b/internal/plugins/helm/v1/chartutil/chart_test.go
@@ -21,10 +21,10 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/repo/repotest"
 	"k8s.io/apimachinery/pkg/runtime/schema"
-	"sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang"
+	cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3"
+	"sigs.k8s.io/kubebuilder/v3/pkg/model/resource"
 
 	"github.com/operator-framework/operator-sdk/internal/plugins/helm/v1/chartutil"
 )
@@ -202,27 +202,21 @@ type createChartTestCase struct {
 	helmChartVersion string
 	helmChartRepo    string
 
-	expectResource     *golang.Options
+	expectResource     *resource.Resource
 	expectChartName    string
 	expectChartVersion string
 	expectErr          bool
 }
 
-func mustNewResource(group, version, kind string) *golang.Options {
-	r := &golang.Options{
-		Namespaced: true,
-		Group:      group,
-		Version:    version,
-		Kind:       kind,
-	}
-	return r
+func mustNewResource(group, version, kind string) *resource.Resource {
+	opts := chartutil.CreateOptions{}
+	opts.GVK.Group = group
+	opts.GVK.Version = version
+	opts.GVK.Kind = kind
+	return opts.NewResource(cfgv3.New())
 }
 
 func runTestCase(t *testing.T, testDir string, tc createChartTestCase) {
-	outputDir := filepath.Join(testDir, "output")
-	assert.NoError(t, os.Mkdir(outputDir, 0755))
-	defer os.RemoveAll(outputDir)
-
 	os.Setenv("XDG_CONFIG_HOME", filepath.Join(testDir, ".config"))
 	os.Setenv("XDG_CACHE_HOME", filepath.Join(testDir, ".cache"))
 	os.Setenv("HELM_REPOSITORY_CONFIG", filepath.Join(testDir, "repositories.yaml"))
@@ -242,7 +236,7 @@ func runTestCase(t *testing.T, testDir string, tc createChartTestCase) {
 		Version: tc.helmChartVersion,
 		Repo:    tc.helmChartRepo,
 	}
-	resource, chrt, err := chartutil.CreateChart(outputDir, opts)
+	resource, chrt, err := chartutil.CreateChart(cfgv3.New(), opts)
 	if tc.expectErr {
 		assert.Error(t, err)
 		return
@@ -254,11 +248,4 @@ func runTestCase(t *testing.T, testDir string, tc createChartTestCase) {
 	assert.Equal(t, tc.expectResource, resource)
 	assert.Equal(t, tc.expectChartName, chrt.Name())
 	assert.Equal(t, tc.expectChartVersion, chrt.Metadata.Version)
-
-	loadedChart, err := loader.Load(filepath.Join(outputDir, chartutil.HelmChartsDir, chrt.Name()))
-	if err != nil {
-		t.Fatalf("Could not load chart from expected location: %s", err)
-	}
-
-	assert.Equal(t, loadedChart, chrt)
 }
diff --git a/test/common/scorecard.go b/test/common/scorecard.go
new file mode 100644
index 00000000..3cb8d5f9
--- /dev/null
+++ b/test/common/scorecard.go
@@ -0,0 +1,95 @@
+// Copyright 2021 The Operator-SDK 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 common
+
+import (
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"strings"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3"
+	"github.com/operator-framework/operator-sdk/internal/testutils"
+)
+
+// ScorecardSpec runs a set of scorecard tests common to all operator types.
+func ScorecardSpec(tc *testutils.TestContext, operatorType string) func() {
+	return func() {
+		var (
+			err         error
+			cmd         *exec.Cmd
+			outputBytes []byte
+			output      v1alpha3.TestList
+		)
+
+		It("should run a single scorecard test successfully", func() {
+			cmd = exec.Command(tc.BinaryName, "scorecard", "bundle",
+				"--selector", "suite=basic",
+				"--output", "json",
+				"--wait-time", "2m")
+			outputBytes, err = tc.Run(cmd)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(json.Unmarshal(outputBytes, &output)).To(Succeed())
+
+			Expect(output.Items).To(HaveLen(1))
+			results := output.Items[0].Status.Results
+			Expect(results).To(HaveLen(1))
+			Expect(results[0].Name).To(Equal("basic-check-spec"))
+			Expect(results[0].State).To(Equal(v1alpha3.PassState))
+		})
+
+		It("should run all enabled scorecard tests successfully", func() {
+			cmd = exec.Command(tc.BinaryName, "scorecard", "bundle",
+				"--output", "json",
+				"--wait-time", "4m")
+			outputBytes, err = tc.Run(cmd)
+			// Some tests are expected to fail, which results in scorecard exiting 1.
+			Expect(err).To(HaveOccurred())
+			Expect(json.Unmarshal(outputBytes, &output)).To(Succeed())
+
+			expected := map[string]v1alpha3.State{
+				// Basic suite.
+				"basic-check-spec": v1alpha3.PassState,
+				// OLM suite.
+				"olm-bundle-validation":    v1alpha3.PassState,
+				"olm-crds-have-validation": v1alpha3.FailState,
+				"olm-crds-have-resources":  v1alpha3.FailState,
+				"olm-spec-descriptors":     v1alpha3.FailState,
+				"olm-status-descriptors":   v1alpha3.FailState,
+			}
+			if strings.ToLower(operatorType) == "go" {
+				// Go projects have generated CRD validation.
+				expected["olm-crds-have-validation"] = v1alpha3.PassState
+				// The Go sample project tests a custom suite.
+				expected["customtest1"] = v1alpha3.PassState
+				expected["customtest2"] = v1alpha3.PassState
+			}
+
+			Expect(output.Items).To(HaveLen(len(expected)))
+			for i := 0; i < len(output.Items); i++ {
+				results := output.Items[i].Status.Results
+				Expect(results).To(HaveLen(1))
+				Expect(results[0].Name).NotTo(BeEmpty())
+				fmt.Fprintln(GinkgoWriter, "    - Name: ", results[0].Name)
+				fmt.Fprintln(GinkgoWriter, "      Expected: ", expected[results[0].Name])
+				fmt.Fprintln(GinkgoWriter, "      Output: ", results[0].State)
+				Expect(results[0].State).To(Equal(expected[results[0].Name]))
+			}
+		})
+	}
+}
diff --git a/test/e2e/ansible/cluster_test.go b/test/e2e/ansible/cluster_test.go
index 3cf803b7..2798fff8 100644
--- a/test/e2e/ansible/cluster_test.go
+++ b/test/e2e/ansible/cluster_test.go
@@ -70,7 +70,7 @@ var _ = Describe("Running ansible projects", func() {
 		It("should run correctly in a cluster", func() {
 			By("checking if the Operator project Pod is running")
 			verifyControllerUp := func() error {
-				By("getting the controller-manager pod name")
+				// Get the controller-manager pod name
 				podOutput, err := tc.Kubectl.Get(
 					true,
 					"pods", "-l", "control-plane=controller-manager",
@@ -79,8 +79,6 @@ var _ = Describe("Running ansible projects", func() {
 				if err != nil {
 					return fmt.Errorf("could not get pods: %v", err)
 				}
-
-				By("ensuring the created controller-manager Pod")
 				podNames := kbtestutils.GetNonEmptyLines(podOutput)
 				if len(podNames) != 1 {
 					return fmt.Errorf("expecting 1 pod, have %d", len(podNames))
@@ -90,12 +88,12 @@ var _ = Describe("Running ansible projects", func() {
 					return fmt.Errorf("expecting pod name %q to contain %q", controllerPodName, "controller-manager")
 				}
 
-				By("checking the controller-manager Pod is running")
+				// Ensure the controller-manager Pod is running.
 				status, err := tc.Kubectl.Get(
 					true,
 					"pods", controllerPodName, "-o", "jsonpath={.status.phase}")
 				if err != nil {
-					return fmt.Errorf("failed to get pod stauts for %q: %v", controllerPodName, err)
+					return fmt.Errorf("failed to get pod status for %q: %v", controllerPodName, err)
 				}
 				if status != "Running" {
 					return fmt.Errorf("controller pod in %s status", status)
@@ -267,7 +265,7 @@ var _ = Describe("Running ansible projects", func() {
 			By("creating a pod with curl image")
 			// todo: the flag --generator=run-pod/v1 is deprecated, however, shows that besides
 			// it should not make any difference and work locally successfully when the flag is removed
-			// travis has been failing and the curl pod is not found when the flag is not used
+			// CI has been failing and the curl pod is not found when the flag is not used
 			cmdOpts := []string{
 				"run", "--generator=run-pod/v1", "curl", "--image=curlimages/curl:7.68.0", "--restart=OnFailure", "--",
 				"curl", "-v", "-k", "-H", fmt.Sprintf(`Authorization: Bearer %s`, token),
diff --git a/test/e2e/ansible/scorecard_test.go b/test/e2e/ansible/scorecard_test.go
index 0afddd4a..aaae1001 100644
--- a/test/e2e/ansible/scorecard_test.go
+++ b/test/e2e/ansible/scorecard_test.go
@@ -15,63 +15,9 @@
 package e2e_ansible_test
 
 import (
-	"encoding/json"
-	"fmt"
-	"os/exec"
-
 	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
-	"github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3"
-)
-
-var _ = Describe("Testing Ansible Projects with Scorecard", func() {
-	Context("with operator-sdk", func() {
-		const (
-			OLMBundleValidationTest   = "olm-bundle-validation"
-			OLMCRDsHaveValidationTest = "olm-crds-have-validation"
-			OLMCRDsHaveResourcesTest  = "olm-crds-have-resources"
-			OLMSpecDescriptorsTest    = "olm-spec-descriptors"
-			OLMStatusDescriptorsTest  = "olm-status-descriptors"
-		)
 
-		It("should work successfully with scorecard", func() {
-			By("running basic scorecard tests")
-			var scorecardOutput v1alpha3.TestList
-			runScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=basic",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err := tc.Run(runScorecardCmd)
-			Expect(err).NotTo(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-			Expect(scorecardOutput.Items).To(HaveLen(1))
-			Expect(scorecardOutput.Items[0].Status.Results[0].State).To(Equal(v1alpha3.PassState))
-
-			By("running olm scorecard tests")
-			runOLMScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=olm",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err = tc.Run(runOLMScorecardCmd)
-			Expect(err).To(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-
-			expected := make(map[string]v1alpha3.State)
-			expected[OLMBundleValidationTest] = v1alpha3.PassState
-			expected[OLMCRDsHaveResourcesTest] = v1alpha3.FailState
-			expected[OLMCRDsHaveValidationTest] = v1alpha3.FailState
-			expected[OLMSpecDescriptorsTest] = v1alpha3.FailState
-			expected[OLMStatusDescriptorsTest] = v1alpha3.FailState
+	"github.com/operator-framework/operator-sdk/test/common"
+)
 
-			Expect(len(scorecardOutput.Items)).To(Equal(len(expected)))
-			for a := 0; a < len(scorecardOutput.Items); a++ {
-				fmt.Println("    - Name: ", scorecardOutput.Items[a].Status.Results[0].Name)
-				fmt.Println("      Expected: ", expected[scorecardOutput.Items[a].Status.Results[0].Name])
-				fmt.Println("      Output: ", scorecardOutput.Items[a].Status.Results[0].State)
-				Expect(scorecardOutput.Items[a].Status.Results[0].State).To(Equal(expected[scorecardOutput.Items[a].Status.Results[0].Name]))
-			}
-		})
-	})
-})
+var _ = Describe("scorecard", common.ScorecardSpec(&tc, "ansible"))
diff --git a/test/e2e/ansible/suite_test.go b/test/e2e/ansible/suite_test.go
index 7b398d23..7b439ca9 100644
--- a/test/e2e/ansible/suite_test.go
+++ b/test/e2e/ansible/suite_test.go
@@ -126,9 +126,8 @@ var _ = BeforeSuite(func() {
 		Expect(tc.LoadImageToKindClusterWithName("quay.io/operator-framework/scorecard-test:dev")).To(Succeed())
 	}
 
-	By("creating bundle image")
-	err = tc.GenerateBundle()
-	Expect(err).NotTo(HaveOccurred())
+	By("generating bundle")
+	Expect(tc.GenerateBundle()).To(Succeed())
 })
 
 // AfterSuite run after all the specs have run, regardless of whether any tests have failed to ensures that
diff --git a/test/e2e/go/cluster_test.go b/test/e2e/go/cluster_test.go
index 58c441a8..d91d04d8 100644
--- a/test/e2e/go/cluster_test.go
+++ b/test/e2e/go/cluster_test.go
@@ -62,7 +62,7 @@ var _ = Describe("operator-sdk", func() {
 		It("should run correctly in a cluster", func() {
 			By("checking if the Operator project Pod is running")
 			verifyControllerUp := func() error {
-				By("getting the controller-manager pod name")
+				// Get the controller-manager pod name
 				podOutput, err := tc.Kubectl.Get(
 					true,
 					"pods", "-l", "control-plane=controller-manager",
@@ -71,8 +71,6 @@ var _ = Describe("operator-sdk", func() {
 				if err != nil {
 					return fmt.Errorf("could not get pods: %v", err)
 				}
-
-				By("ensuring the created controller-manager Pod")
 				podNames := kbtestutils.GetNonEmptyLines(podOutput)
 				if len(podNames) != 1 {
 					return fmt.Errorf("expecting 1 pod, have %d", len(podNames))
@@ -82,7 +80,7 @@ var _ = Describe("operator-sdk", func() {
 					return fmt.Errorf("expecting pod name %q to contain %q", controllerPodName, "controller-manager")
 				}
 
-				By("checking the controller-manager Pod is running")
+				// Ensure the controller-manager Pod is running.
 				status, err := tc.Kubectl.Get(
 					true,
 					"pods", controllerPodName, "-o", "jsonpath={.status.phase}")
diff --git a/test/e2e/go/scorecard_test.go b/test/e2e/go/scorecard_test.go
index f028c6f1..a3974c94 100644
--- a/test/e2e/go/scorecard_test.go
+++ b/test/e2e/go/scorecard_test.go
@@ -15,71 +15,9 @@
 package e2e_go_test
 
 import (
-	"encoding/json"
-	"os/exec"
-
 	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
 
-	"github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3"
+	"github.com/operator-framework/operator-sdk/test/common"
 )
 
-var _ = Describe("Testing Go Projects with Scorecard", func() {
-	Context("with operator-sdk", func() {
-		const (
-			OLMBundleValidationTest   = "olm-bundle-validation"
-			OLMCRDsHaveValidationTest = "olm-crds-have-validation"
-			OLMCRDsHaveResourcesTest  = "olm-crds-have-resources"
-			OLMSpecDescriptorsTest    = "olm-spec-descriptors"
-			OLMStatusDescriptorsTest  = "olm-status-descriptors"
-		)
-
-		It("should work successfully with scorecard", func() {
-			By("running basic scorecard tests")
-			var scorecardOutput v1alpha3.TestList
-			runScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=basic",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err := tc.Run(runScorecardCmd)
-			Expect(err).NotTo(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-			Expect(scorecardOutput.Items).To(HaveLen(1))
-			Expect(scorecardOutput.Items[0].Status.Results[0].State).To(Equal(v1alpha3.PassState))
-
-			By("running custom scorecard tests")
-			runScorecardCmd = exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=custom",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err = tc.Run(runScorecardCmd)
-			Expect(err).NotTo(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-			Expect(scorecardOutput.Items).To(HaveLen(2))
-
-			By("running olm scorecard tests")
-			runOLMScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=olm",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err = tc.Run(runOLMScorecardCmd)
-			Expect(err).To(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-
-			resultTable := make(map[string]v1alpha3.State)
-			resultTable[OLMStatusDescriptorsTest] = v1alpha3.FailState
-			resultTable[OLMCRDsHaveResourcesTest] = v1alpha3.FailState
-			resultTable[OLMBundleValidationTest] = v1alpha3.PassState
-			resultTable[OLMSpecDescriptorsTest] = v1alpha3.FailState
-			resultTable[OLMCRDsHaveValidationTest] = v1alpha3.PassState
-
-			Expect(len(scorecardOutput.Items)).To(Equal(len(resultTable)))
-			for a := 0; a < len(scorecardOutput.Items); a++ {
-				Expect(scorecardOutput.Items[a].Status.Results[0].State).To(Equal(resultTable[scorecardOutput.Items[a].Status.Results[0].Name]))
-			}
-		})
-	})
-})
+var _ = Describe("scorecard", common.ScorecardSpec(&tc, "go"))
diff --git a/test/e2e/go/suite_test.go b/test/e2e/go/suite_test.go
index 22ec2aec..4184582f 100644
--- a/test/e2e/go/suite_test.go
+++ b/test/e2e/go/suite_test.go
@@ -83,9 +83,8 @@ var _ = BeforeSuite(func() {
 		Expect(tc.LoadImageToKindClusterWithName("quay.io/operator-framework/custom-scorecard-tests:dev")).To(Succeed())
 	}
 
-	By("creating bundle image")
-	err = tc.GenerateBundle()
-	Expect(err).NotTo(HaveOccurred())
+	By("generating bundle")
+	Expect(tc.GenerateBundle()).To(Succeed())
 
 	By("installing cert manager bundle")
 	Expect(tc.InstallCertManager(false)).To(Succeed())
diff --git a/test/e2e/helm/cluster_test.go b/test/e2e/helm/cluster_test.go
index fe966e10..8baccd4f 100644
--- a/test/e2e/helm/cluster_test.go
+++ b/test/e2e/helm/cluster_test.go
@@ -64,7 +64,7 @@ var _ = Describe("Running Helm projects", func() {
 		It("should run correctly in a cluster", func() {
 			By("checking if the Operator project Pod is running")
 			verifyControllerUp := func() error {
-				By("getting the controller-manager pod name")
+				// Get the controller-manager pod name
 				podOutput, err := tc.Kubectl.Get(
 					true,
 					"pods", "-l", "control-plane=controller-manager",
@@ -73,8 +73,6 @@ var _ = Describe("Running Helm projects", func() {
 				if err != nil {
 					return fmt.Errorf("could not get pods: %v", err)
 				}
-
-				By("ensuring the created controller-manager Pod")
 				podNames := kbtestutils.GetNonEmptyLines(podOutput)
 				if len(podNames) != 1 {
 					return fmt.Errorf("expecting 1 pod, have %d", len(podNames))
@@ -84,12 +82,12 @@ var _ = Describe("Running Helm projects", func() {
 					return fmt.Errorf("expecting pod name %q to contain %q", controllerPodName, "controller-manager")
 				}
 
-				By("checking the controller-manager Pod is running")
+				// Ensure the controller-manager Pod is running.
 				status, err := tc.Kubectl.Get(
 					true,
 					"pods", controllerPodName, "-o", "jsonpath={.status.phase}")
 				if err != nil {
-					return fmt.Errorf("failed to get pod stauts for %q: %v", controllerPodName, err)
+					return fmt.Errorf("failed to get pod status for %q: %v", controllerPodName, err)
 				}
 				if status != "Running" {
 					return fmt.Errorf("controller pod in %s status", status)
@@ -224,7 +222,7 @@ var _ = Describe("Running Helm projects", func() {
 			By("creating a pod with curl image")
 			// todo: the flag --generator=run-pod/v1 is deprecated, however, shows that besides
 			// it should not make any difference and work locally successfully when the flag is removed
-			// travis has been failing and the curl pod is not found when the flag is not used
+			// CI has been failing and the curl pod is not found when the flag is not used
 			cmdOpts := []string{
 				"run", "--generator=run-pod/v1", "curl", "--image=curlimages/curl:7.68.0", "--restart=OnFailure", "--",
 				"curl", "-v", "-k", "-H", fmt.Sprintf(`Authorization: Bearer %s`, token),
diff --git a/test/e2e/helm/scorecard_test.go b/test/e2e/helm/scorecard_test.go
index 8412288c..44d53c3b 100644
--- a/test/e2e/helm/scorecard_test.go
+++ b/test/e2e/helm/scorecard_test.go
@@ -15,64 +15,9 @@
 package e2e_helm_test
 
 import (
-	"encoding/json"
-	"fmt"
-	"os/exec"
-
 	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
 
-	"github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3"
+	"github.com/operator-framework/operator-sdk/test/common"
 )
 
-var _ = Describe("Testing Helm Projects with Scorecard", func() {
-	Context("with operator-sdk", func() {
-		const (
-			OLMBundleValidationTest   = "olm-bundle-validation"
-			OLMCRDsHaveValidationTest = "olm-crds-have-validation"
-			OLMCRDsHaveResourcesTest  = "olm-crds-have-resources"
-			OLMSpecDescriptorsTest    = "olm-spec-descriptors"
-			OLMStatusDescriptorsTest  = "olm-status-descriptors"
-		)
-
-		It("should work successfully with scorecard", func() {
-			By("running basic scorecard tests")
-			var scorecardOutput v1alpha3.TestList
-			runScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=basic",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err := tc.Run(runScorecardCmd)
-			Expect(err).NotTo(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-			Expect(scorecardOutput.Items).To(HaveLen(1))
-			Expect(scorecardOutput.Items[0].Status.Results[0].State).To(Equal(v1alpha3.PassState))
-
-			By("running olm scorecard tests")
-			runOLMScorecardCmd := exec.Command(tc.BinaryName, "scorecard", "bundle",
-				"--selector=suite=olm",
-				"--output=json",
-				"--wait-time=120s")
-			scorecardOutputBytes, err = tc.Run(runOLMScorecardCmd)
-			Expect(err).To(HaveOccurred())
-			err = json.Unmarshal(scorecardOutputBytes, &scorecardOutput)
-			Expect(err).NotTo(HaveOccurred())
-
-			expected := make(map[string]v1alpha3.State)
-			expected[OLMBundleValidationTest] = v1alpha3.PassState
-			expected[OLMCRDsHaveResourcesTest] = v1alpha3.FailState
-			expected[OLMCRDsHaveValidationTest] = v1alpha3.FailState
-			expected[OLMSpecDescriptorsTest] = v1alpha3.FailState
-			expected[OLMStatusDescriptorsTest] = v1alpha3.FailState
-
-			Expect(len(scorecardOutput.Items)).To(Equal(len(expected)))
-			for a := 0; a < len(scorecardOutput.Items); a++ {
-				fmt.Println("    - Name: ", scorecardOutput.Items[a].Status.Results[0].Name)
-				fmt.Println("      Expected: ", expected[scorecardOutput.Items[a].Status.Results[0].Name])
-				fmt.Println("      Output: ", scorecardOutput.Items[a].Status.Results[0].State)
-				Expect(scorecardOutput.Items[a].Status.Results[0].State).To(Equal(expected[scorecardOutput.Items[a].Status.Results[0].Name]))
-			}
-		})
-	})
-})
+var _ = Describe("scorecard", common.ScorecardSpec(&tc, "helm"))
diff --git a/test/e2e/helm/suite_test.go b/test/e2e/helm/suite_test.go
index 1c19b149..91b693d8 100644
--- a/test/e2e/helm/suite_test.go
+++ b/test/e2e/helm/suite_test.go
@@ -87,9 +87,8 @@ var _ = BeforeSuite(func() {
 		Expect(tc.LoadImageToKindClusterWithName("quay.io/operator-framework/scorecard-test:dev")).To(Succeed())
 	}
 
-	By("creating bundle image")
-	err = tc.GenerateBundle()
-	Expect(err).NotTo(HaveOccurred())
+	By("generating bundle")
+	Expect(tc.GenerateBundle()).To(Succeed())
 })
 
 // AfterSuite run after all the specs have run, regardless of whether any tests have failed to ensures that
diff --git a/testdata/ansible/memcached-operator/bundle.Dockerfile b/testdata/ansible/memcached-operator/bundle.Dockerfile
index 4f7ff821..432577b9 100644
--- a/testdata/ansible/memcached-operator/bundle.Dockerfile
+++ b/testdata/ansible/memcached-operator/bundle.Dockerfile
@@ -1,12 +1,17 @@
 FROM scratch
 
+# Core bundle labels.
 LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
 LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
 LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
 LABEL operators.operatorframework.io.bundle.package.v1=memcached-operator
 LABEL operators.operatorframework.io.bundle.channels.v1=alpha
-LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Labels for testing.
 LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1
+LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Copy files to locations specified by labels.
 COPY bundle/manifests /manifests/
 COPY bundle/metadata /metadata/
 COPY bundle/tests/scorecard /tests/scorecard/
diff --git a/testdata/ansible/memcached-operator/bundle/metadata/annotations.yaml b/testdata/ansible/memcached-operator/bundle/metadata/annotations.yaml
index 9c65a477..2a94286a 100644
--- a/testdata/ansible/memcached-operator/bundle/metadata/annotations.yaml
+++ b/testdata/ansible/memcached-operator/bundle/metadata/annotations.yaml
@@ -1,8 +1,11 @@
 annotations:
-  operators.operatorframework.io.bundle.channels.v1: alpha
-  operators.operatorframework.io.bundle.manifests.v1: manifests/
+  # Core bundle annotations.
   operators.operatorframework.io.bundle.mediatype.v1: registry+v1
+  operators.operatorframework.io.bundle.manifests.v1: manifests/
   operators.operatorframework.io.bundle.metadata.v1: metadata/
   operators.operatorframework.io.bundle.package.v1: memcached-operator
-  operators.operatorframework.io.test.config.v1: tests/scorecard/
+  operators.operatorframework.io.bundle.channels.v1: alpha
+
+  # Annotations for testing.
   operators.operatorframework.io.test.mediatype.v1: scorecard+v1
+  operators.operatorframework.io.test.config.v1: tests/scorecard/
diff --git a/testdata/go/v2/memcached-operator/bundle.Dockerfile b/testdata/go/v2/memcached-operator/bundle.Dockerfile
index 4f7ff821..432577b9 100644
--- a/testdata/go/v2/memcached-operator/bundle.Dockerfile
+++ b/testdata/go/v2/memcached-operator/bundle.Dockerfile
@@ -1,12 +1,17 @@
 FROM scratch
 
+# Core bundle labels.
 LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
 LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
 LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
 LABEL operators.operatorframework.io.bundle.package.v1=memcached-operator
 LABEL operators.operatorframework.io.bundle.channels.v1=alpha
-LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Labels for testing.
 LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1
+LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Copy files to locations specified by labels.
 COPY bundle/manifests /manifests/
 COPY bundle/metadata /metadata/
 COPY bundle/tests/scorecard /tests/scorecard/
diff --git a/testdata/go/v2/memcached-operator/bundle/metadata/annotations.yaml b/testdata/go/v2/memcached-operator/bundle/metadata/annotations.yaml
index 9c65a477..2a94286a 100644
--- a/testdata/go/v2/memcached-operator/bundle/metadata/annotations.yaml
+++ b/testdata/go/v2/memcached-operator/bundle/metadata/annotations.yaml
@@ -1,8 +1,11 @@
 annotations:
-  operators.operatorframework.io.bundle.channels.v1: alpha
-  operators.operatorframework.io.bundle.manifests.v1: manifests/
+  # Core bundle annotations.
   operators.operatorframework.io.bundle.mediatype.v1: registry+v1
+  operators.operatorframework.io.bundle.manifests.v1: manifests/
   operators.operatorframework.io.bundle.metadata.v1: metadata/
   operators.operatorframework.io.bundle.package.v1: memcached-operator
-  operators.operatorframework.io.test.config.v1: tests/scorecard/
+  operators.operatorframework.io.bundle.channels.v1: alpha
+
+  # Annotations for testing.
   operators.operatorframework.io.test.mediatype.v1: scorecard+v1
+  operators.operatorframework.io.test.config.v1: tests/scorecard/
diff --git a/testdata/go/v2/memcached-operator/controllers/memcached_controller.go b/testdata/go/v2/memcached-operator/controllers/memcached_controller.go
index 020ec0ef..d2dd12a2 100644
--- a/testdata/go/v2/memcached-operator/controllers/memcached_controller.go
+++ b/testdata/go/v2/memcached-operator/controllers/memcached_controller.go
@@ -23,6 +23,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"reflect"
+	"time"
 
 	"context"
 
@@ -104,8 +105,10 @@ func (r *MemcachedReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
 			log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
 			return ctrl.Result{}, err
 		}
-		// Spec updated - return and requeue
-		return ctrl.Result{Requeue: true}, nil
+		// Ask to requeue after 1 minute in order to give enough time for the
+		// pods be created on the cluster side and the operand be able
+		// to do the next update step accurately.
+		return ctrl.Result{RequeueAfter: time.Minute}, nil
 	}
 
 	// Update the Memcached status with the pod names
diff --git a/testdata/go/v3/memcached-operator/bundle.Dockerfile b/testdata/go/v3/memcached-operator/bundle.Dockerfile
index 4f7ff821..432577b9 100644
--- a/testdata/go/v3/memcached-operator/bundle.Dockerfile
+++ b/testdata/go/v3/memcached-operator/bundle.Dockerfile
@@ -1,12 +1,17 @@
 FROM scratch
 
+# Core bundle labels.
 LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
 LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
 LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
 LABEL operators.operatorframework.io.bundle.package.v1=memcached-operator
 LABEL operators.operatorframework.io.bundle.channels.v1=alpha
-LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Labels for testing.
 LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1
+LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Copy files to locations specified by labels.
 COPY bundle/manifests /manifests/
 COPY bundle/metadata /metadata/
 COPY bundle/tests/scorecard /tests/scorecard/
diff --git a/testdata/go/v3/memcached-operator/bundle/manifests/memcached-operator.clusterserviceversion.yaml b/testdata/go/v3/memcached-operator/bundle/manifests/memcached-operator.clusterserviceversion.yaml
index bc44fa96..09c6c157 100644
--- a/testdata/go/v3/memcached-operator/bundle/manifests/memcached-operator.clusterserviceversion.yaml
+++ b/testdata/go/v3/memcached-operator/bundle/manifests/memcached-operator.clusterserviceversion.yaml
@@ -81,6 +81,7 @@ spec:
           verbs:
           - get
           - list
+          - watch
         - apiGroups:
           - authentication.k8s.io
           resources:
diff --git a/testdata/go/v3/memcached-operator/bundle/metadata/annotations.yaml b/testdata/go/v3/memcached-operator/bundle/metadata/annotations.yaml
index 9c65a477..2a94286a 100644
--- a/testdata/go/v3/memcached-operator/bundle/metadata/annotations.yaml
+++ b/testdata/go/v3/memcached-operator/bundle/metadata/annotations.yaml
@@ -1,8 +1,11 @@
 annotations:
-  operators.operatorframework.io.bundle.channels.v1: alpha
-  operators.operatorframework.io.bundle.manifests.v1: manifests/
+  # Core bundle annotations.
   operators.operatorframework.io.bundle.mediatype.v1: registry+v1
+  operators.operatorframework.io.bundle.manifests.v1: manifests/
   operators.operatorframework.io.bundle.metadata.v1: metadata/
   operators.operatorframework.io.bundle.package.v1: memcached-operator
-  operators.operatorframework.io.test.config.v1: tests/scorecard/
+  operators.operatorframework.io.bundle.channels.v1: alpha
+
+  # Annotations for testing.
   operators.operatorframework.io.test.mediatype.v1: scorecard+v1
+  operators.operatorframework.io.test.config.v1: tests/scorecard/
diff --git a/testdata/go/v3/memcached-operator/config/rbac/role.yaml b/testdata/go/v3/memcached-operator/config/rbac/role.yaml
index e32a4510..1ba7b9d1 100644
--- a/testdata/go/v3/memcached-operator/config/rbac/role.yaml
+++ b/testdata/go/v3/memcached-operator/config/rbac/role.yaml
@@ -51,3 +51,4 @@ rules:
   verbs:
   - get
   - list
+  - watch
diff --git a/testdata/go/v3/memcached-operator/controllers/memcached_controller.go b/testdata/go/v3/memcached-operator/controllers/memcached_controller.go
index 6fe590eb..44ab934c 100644
--- a/testdata/go/v3/memcached-operator/controllers/memcached_controller.go
+++ b/testdata/go/v3/memcached-operator/controllers/memcached_controller.go
@@ -23,6 +23,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"reflect"
+	"time"
 
 	"context"
 
@@ -45,7 +46,7 @@ type MemcachedReconciler struct {
 //+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch
 //+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update
 //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;
+//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
 
 // Reconcile is part of the main kubernetes reconciliation loop which aims to
 // move the current state of the cluster closer to the desired state.
@@ -103,8 +104,10 @@ func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
 			log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
 			return ctrl.Result{}, err
 		}
-		// Spec updated - return and requeue
-		return ctrl.Result{Requeue: true}, nil
+		// Ask to requeue after 1 minute in order to give enough time for the
+		// pods be created on the cluster side and the operand be able
+		// to do the next update step accurately.
+		return ctrl.Result{RequeueAfter: time.Minute}, nil
 	}
 
 	// Update the Memcached status with the pod names
diff --git a/testdata/helm/memcached-operator/bundle.Dockerfile b/testdata/helm/memcached-operator/bundle.Dockerfile
index 4f7ff821..432577b9 100644
--- a/testdata/helm/memcached-operator/bundle.Dockerfile
+++ b/testdata/helm/memcached-operator/bundle.Dockerfile
@@ -1,12 +1,17 @@
 FROM scratch
 
+# Core bundle labels.
 LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
 LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
 LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
 LABEL operators.operatorframework.io.bundle.package.v1=memcached-operator
 LABEL operators.operatorframework.io.bundle.channels.v1=alpha
-LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Labels for testing.
 LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1
+LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/
+
+# Copy files to locations specified by labels.
 COPY bundle/manifests /manifests/
 COPY bundle/metadata /metadata/
 COPY bundle/tests/scorecard /tests/scorecard/
diff --git a/testdata/helm/memcached-operator/bundle/metadata/annotations.yaml b/testdata/helm/memcached-operator/bundle/metadata/annotations.yaml
index 9c65a477..2a94286a 100644
--- a/testdata/helm/memcached-operator/bundle/metadata/annotations.yaml
+++ b/testdata/helm/memcached-operator/bundle/metadata/annotations.yaml
@@ -1,8 +1,11 @@
 annotations:
-  operators.operatorframework.io.bundle.channels.v1: alpha
-  operators.operatorframework.io.bundle.manifests.v1: manifests/
+  # Core bundle annotations.
   operators.operatorframework.io.bundle.mediatype.v1: registry+v1
+  operators.operatorframework.io.bundle.manifests.v1: manifests/
   operators.operatorframework.io.bundle.metadata.v1: metadata/
   operators.operatorframework.io.bundle.package.v1: memcached-operator
-  operators.operatorframework.io.test.config.v1: tests/scorecard/
+  operators.operatorframework.io.bundle.channels.v1: alpha
+
+  # Annotations for testing.
   operators.operatorframework.io.test.mediatype.v1: scorecard+v1
+  operators.operatorframework.io.test.config.v1: tests/scorecard/
diff --git a/website/content/en/docs/contribution-guidelines/testing.md b/website/content/en/docs/contribution-guidelines/testing.md
index bce6c72a..5064d892 100644
--- a/website/content/en/docs/contribution-guidelines/testing.md
+++ b/website/content/en/docs/contribution-guidelines/testing.md
@@ -34,7 +34,7 @@ make test-all
 
 ## Local Test Environment
 
-If running tests locally, access to a Kubernetes cluster of server version v1.11.3 or higher is required.
+If running tests locally, access to a Kubernetes cluster of a [compatible version][k8s-version-compat] is required.
 These tests require `KUBECONFIG` be set or kubeconfig file be present in a default location like `$HOME/.kube/config`.
 
 You will also need to set up an `envtest` environment for cluster tests. Follow [this doc][envtest-setup]
@@ -54,3 +54,4 @@ All the tests are run through the [`Makefile`][makefile]. Run `make help` for a
 [kind]: https://kind.sigs.k8s.io/
 [envtest-setup]: /docs/building-operators/golang/references/envtest-setup
 [makefile]: https://github.com/operator-framework/operator-sdk/blob/master/Makefile
+[k8s-version-compat]:/docs/overview#kubernetes-version-compatibility
diff --git a/website/content/en/docs/olm-integration/testing-deployment.md b/website/content/en/docs/olm-integration/testing-deployment.md
index 0cf40992..d281cc2e 100644
--- a/website/content/en/docs/olm-integration/testing-deployment.md
+++ b/website/content/en/docs/olm-integration/testing-deployment.md
@@ -22,7 +22,7 @@ operator-sdk run bundle <bundle-image> [--index-image=] [--kubeconfig=] [--names
 ```
 
 Let's look at the configuration shared between `run bundle`, `run
-packagemanifests` and `cleanup`:
+packagemanifests`, `run bundle-upgrade` and `cleanup`:
 
 - **kubeconfig**: the local path to a kubeconfig. This uses well-defined default
   loading rules to load the config if empty.
@@ -102,6 +102,21 @@ Let's look at the anatomy of the `run packagemanifests` configuration model:
   found in **manifests-dir**, e.g. `packagemanifests/0.0.1` in an Operator
   SDK project.
 
+## `operator-sdk run bundle-upgrade` command overview
+`operator-sdk run bundle-upgrade` assumes OLM is already installed and running on your 
+cluster and that the Operator has a valid [bundle][bundle-format]. It also assumes that 
+the previous version of the Operator was deployed on the cluster using `run bundle` command 
+or traditionally via OLM. See the [CLI overview][doc-cli-overview] for commands to work 
+with an OLM installation and generate a bundle.
+
+```
+operator-sdk run bundle-upgrade <bundle-image> [--kubeconfig=] [--namespace=] [--timeout=] 
+```
+Let's look at the anatomy of the `run bundle-upgrade` configuration model:
+
+- **bundle-image**: specifies the Operator bundle image, this is a
+  required parameter. The bundle image must be pullable.
+
 ## `operator-sdk cleanup` command overview
 
 `operator-sdk cleanup` assumes an Operator was deployed using `run bundle` or
@@ -129,12 +144,13 @@ Let's look at the anatomy of the `cleanup` configuration model:
 
 ### Caveats
 
-- `run bundle`, `run packagemanifests`, and `cleanup` are intended to be used for testing purposes only,
-since this command creates a transient image registry that should not be used in production.
+- `run bundle`, `run bundle-upgrade`, `run packagemanifests`, and `cleanup` are intended to be used for testing purposes only,
+since these commands create a transient image registry that should not be used in production.
 Typically a registry is deployed separately and a set of catalog manifests are created in the cluster
 to inform OLM of that registry and which Operator versions it can deploy and where to deploy the Operator.
-- `run bundle` and `run packagemanifests` can only deploy one Operator and one version of that Operator at a time,
-hence its intended purpose being testing only.
+- `run bundle` and `run packagemanifests` can only deploy one Operator and one version of that Operator at a time, 
+and `run bundle-upgrade` can only upgrade one Operator and one version of that Operator at a time, 
+hence their intended purpose being testing only.
 
 
 [olm]:https://github.com/operator-framework/operator-lifecycle-manager/