Repository: SFDO-Tooling/CumulusCI
Pull request #3383 (2022-10-06 17:22:23)

Refactor `deploy` to support complex source transformations
This PR makes significant changes to the structure of the `deploy` task to provide additional automation capabilities that support flexible source transforms during the deployment operation.

Previously, `deploy` ran a sequence of four transformations on source before sending it to the platform: namespace injection; static resource bundling; meta-XML cleaning; and cleaning of FeatureParameter metadata for Unlocked Packages. Additional transformations, such as `package.xml` updating, replacement of tokens other than namespace tokens, such as `//cumulusci-deprecated` and user-defined replacements, are done out-of-band by mutating the working directory. This situation is undesirable for a number of reasons.

This PR refactors `deploy` to create a pipeline of transforms that run on the deployment package before it's sent to the platform. The four existing transforms have been reimplemented using this pipeline; additionally, a `FindReplace` transform has been added. Users can customize the transforms used by `deploy` (other than the four default transforms) in `cumulusci.yml`.

Because this is a high-risk change, integration tests against `MetadataPackageZipBuilder` are introduced in the initial commit and pass on existing behavior. These tests continue to pass on each commit in the refactor.

davisagli: @davidmreed Love that this is happening! Missing some files, though?
davidmreed: @davisagli Yep, I'm restructuring my branch so I can show a throughline of valid integration tests.
davidmreed: This PR covers W-10381577, W-11744439, and W-11744433.

Patch:
diff --git a/cumulusci/core/source_transforms/transforms.py b/cumulusci/core/source_transforms/transforms.py
new file mode 100644
index 000000000..e27aa0283
--- /dev/null
+++ b/cumulusci/core/source_transforms/transforms.py
@@ -0,0 +1,349 @@
+import abc
+import functools
+import io
+import os
+import typing as T
+import zipfile
+from logging import Logger
+from pathlib import Path
+from zipfile import ZipFile
+
+from pydantic import BaseModel, root_validator
+
+from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.utils import (
+    cd,
+    inject_namespace,
+    strip_namespace,
+    temporary_dir,
+    tokenize_namespace,
+    zip_clean_metaxml,
+)
+from cumulusci.utils.xml import metadata_tree
+from cumulusci.utils.ziputils import process_text_in_zipfile
+
+
+class SourceTransform(abc.ABC):
+    """Abstract base class for a transformation applied to a Metadata API deployment package"""
+
+    options_model: T.Optional[T.Type[BaseModel]]
+    identifier: str
+
+    def __init__(self):
+        ...
+
+    @abc.abstractmethod
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        ...
+
+
+class SourceTransformSpec(BaseModel):
+    transform: str
+    options: T.Optional[dict]
+
+    def parsed_options(self) -> T.Optional[BaseModel]:
+        transform_cls = get_available_transforms()[self.transform]
+        if transform_cls.options_model:
+            return transform_cls.options_model.parse_obj(self.options or {})
+
+        return None
+
+    @root_validator
+    def validate_spec(cls, values):
+        transform = values.get("transform")
+        if transform not in get_available_transforms():
+            raise ValueError(f"Transform {transform} is not valid")
+
+        transform_cls = get_available_transforms()[transform]
+
+        if transform_cls.options_model:
+            transform_cls.options_model.parse_obj(values.get("options"))
+
+        return values
+
+    def as_transform(self) -> SourceTransform:
+        transform_cls = get_available_transforms()[self.transform]
+        if transform_cls.options_model:
+            return transform_cls(self.parsed_options())  # type: ignore
+        else:
+            return transform_cls()
+
+
+class SourceTransformList(BaseModel):
+    __root__: T.List[SourceTransformSpec]
+
+    @root_validator(pre=True)
+    def validate_spec_list(cls, values):
+        values["__root__"] = [
+            {"transform": s} if isinstance(s, str) else s for s in values["__root__"]
+        ]
+
+        return values
+
+    def as_transforms(self) -> T.List[SourceTransform]:
+        return [
+            get_available_transforms()[t]() if isinstance(t, str) else t.as_transform()
+            for t in self.__root__
+        ]
+
+
+class NamespaceInjectionOptions(BaseModel):
+    namespace_tokenize: T.Optional[str]
+    namespace_inject: T.Optional[str]
+    namespace_strip: T.Optional[str]
+    unmanaged: bool = True
+    namespaced_org: bool = False
+
+
+class NamespaceInjectionTransform(SourceTransform):
+    """Source transform that applies namespace injection, stripping, and tokenization."""
+
+    options_model = NamespaceInjectionOptions
+    options: NamespaceInjectionOptions
+
+    identifier = "inject_namespace"
+
+    def __init__(self, options: NamespaceInjectionOptions):
+        self.options = options
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        if self.options.namespace_tokenize:
+            logger.info(
+                f"Tokenizing namespace prefix {self.options.namespace_tokenize}__"
+            )
+            zf = process_text_in_zipfile(
+                zf,
+                functools.partial(
+                    tokenize_namespace,
+                    namespace=self.options.namespace_tokenize,
+                    logger=logger,
+                ),
+            )
+        if self.options.namespace_inject:
+            managed = not self.options.unmanaged
+            if managed:
+                logger.info(
+                    "Replacing namespace tokens from metadata with namespace prefix  "
+                    f"{self.options.namespace_inject}__"
+                )
+            else:
+                logger.info(
+                    "Stripping namespace tokens from metadata for unmanaged deployment"
+                )
+            zf = process_text_in_zipfile(
+                zf,
+                functools.partial(
+                    inject_namespace,
+                    namespace=self.options.namespace_inject,
+                    managed=managed,
+                    namespaced_org=self.options.namespaced_org,
+                    logger=logger,
+                ),
+            )
+        if self.options.namespace_strip:
+            logger.info("Stripping namespace tokens from metadata")
+            zf = process_text_in_zipfile(
+                zf,
+                functools.partial(
+                    strip_namespace,
+                    namespace=self.options.namespace_strip,
+                    logger=logger,
+                ),
+            )
+
+        return zf
+
+
+class RemoveFeatureParametersTransform(SourceTransform):
+    """Source transform that removes Feature Parameters. Intended for use on Unlocked Package builds."""
+
+    options_model = None
+
+    identifier = "remove_feature_parameters"
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        package_xml = None
+        zip_dest = ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
+        for name in zf.namelist():
+            if name == "package.xml":
+                package_xml = zf.open(name)
+            elif name.startswith("featureParameters/"):
+                # skip feature parameters
+                logger.info(f"Skipping {name} because Feature Parameters are omitted.")
+            else:
+                content = zf.read(name)
+                zip_dest.writestr(name, content)
+
+        # Remove from package.xml
+        if package_xml is not None:
+            package = metadata_tree.parse(package_xml)
+            for mdtype in (
+                "FeatureParameterInteger",
+                "FeatureParameterBoolean",
+                "FeatureParameterDate",
+            ):
+                section = package.find("types", name=mdtype)
+                if section is not None:
+                    package.remove(section)
+            package_xml = package.tostring(xml_declaration=True)
+            zip_dest.writestr("package.xml", package_xml)
+
+        return zip_dest
+
+
+class CleanMetaXMLTransform(SourceTransform):
+    """Source transform that cleans *-meta.xml files of references to specific package versions."""
+
+    options_model = None
+
+    identifier = "clean_meta_xml"
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        logger.info("Cleaning meta.xml files of packageVersion elements for deploy")
+        return zip_clean_metaxml(zf)
+
+
+class BundleStaticResourcesOptions(BaseModel):
+    static_resource_path: str
+
+
+class BundleStaticResourcesTransform(SourceTransform):
+    """Source transform that zips static resource content from an external path"""
+
+    options_model = BundleStaticResourcesOptions
+    options: BundleStaticResourcesOptions
+    identifier = "bundle_static_resources"
+
+    def __init__(self, options: BundleStaticResourcesOptions):
+        self.options = options
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        path = os.path.realpath(self.options.static_resource_path)
+
+        # Copy existing files to new zipfile
+        zip_dest = zipfile.ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
+        package_xml = None
+        for name in zf.namelist():
+            if name == "package.xml":
+                package_xml = zf.open(name)
+            else:
+                content = zf.read(name)
+                zip_dest.writestr(name, content)
+
+        if not package_xml:
+            raise Exception("No package.xml found; cannot zip Static Resources")
+
+        # Build static resource bundles and add to package
+        with temporary_dir():
+            os.mkdir("staticresources")
+            bundles = []
+            for name in os.listdir(path):
+                bundle_relpath = os.path.join(self.options.static_resource_path, name)
+                bundle_path = os.path.join(path, name)
+                if not os.path.isdir(bundle_path):
+                    continue
+                logger.info(f"Zipping {bundle_relpath} to add to staticresources")
+
+                # Add resource-meta.xml file
+                meta_name = f"{name}.resource-meta.xml"
+                meta_path = os.path.join(path, meta_name)
+                with open(meta_path, "rb") as f:
+                    zip_dest.writestr(f"staticresources/{meta_name}", f.read())
+
+                # Add bundle
+                zip_path = os.path.join("staticresources", f"{name}.resource")
+                with open(zip_path, "wb") as bundle_fp:
+                    bundle_zip = zipfile.ZipFile(bundle_fp, "w", zipfile.ZIP_DEFLATED)
+                    with cd(bundle_path):
+                        for root, _, files in os.walk("."):
+                            for f in files:
+                                resource_file = os.path.join(root, f)
+                                bundle_zip.write(resource_file)
+                    bundle_zip.close()
+                zip_dest.write(zip_path)
+                bundles.append(name)
+
+        # Update package.xml
+        package = metadata_tree.parse(package_xml)
+        sections = package.findall("types", name="StaticResource")
+        section = sections[0] if sections else None
+        if not section:
+            section = package.append("types")
+            section.append("name", text="StaticResource")
+        for name in sorted(bundles):
+            section.insert_before(section.find("name"), tag="members", text=name)
+        package_xml = package.tostring(xml_declaration=True)
+        zip_dest.writestr("package.xml", package_xml)
+
+        return zip_dest
+
+
+class FindReplaceBaseSpec(BaseModel, abc.ABC):
+    find: str
+    paths: T.Optional[T.List[Path]] = None
+
+    @abc.abstractmethod
+    def get_replace_string(self) -> str:
+        ...
+
+
+class FindReplaceSpec(FindReplaceBaseSpec):
+    replace: str
+
+    def get_replace_string(self) -> str:
+        return self.replace
+
+
+class FindReplaceEnvSpec(FindReplaceBaseSpec):
+    replace_env: str
+
+    def get_replace_string(self) -> str:
+        try:
+            return os.environ[self.replace_env]
+        except KeyError:
+            raise TaskOptionsError(
+                f"Transform {FindReplaceTransform.identifier} could not get replacement value from environment variable {self.replace_env}"
+            )
+
+
+class FindReplaceTransformOptions(BaseModel):
+    patterns: T.List[T.Union[FindReplaceSpec, FindReplaceEnvSpec]]
+
+
+class FindReplaceTransform(SourceTransform):
+    """Source transform that applies one or more find-and-replace patterns."""
+
+    options_model = FindReplaceTransformOptions
+    options: FindReplaceTransformOptions
+
+    identifier = "find_replace"
+
+    def __init__(self, options: FindReplaceTransformOptions):
+        self.options = options
+
+    def process(self, zf: ZipFile, logger: Logger) -> ZipFile:
+        def process_file(filename: str, content: str) -> T.Tuple[str, str]:
+            path = Path(filename)
+            for spec in self.options.patterns:
+                if not spec.paths or any(
+                    parent in path.parents for parent in spec.paths
+                ):
+                    content = content.replace(spec.find, spec.get_replace_string())
+
+            return (filename, content)
+
+        return process_text_in_zipfile(zf, process_file)
+
+
+def get_available_transforms() -> T.Dict[str, T.Type[SourceTransform]]:
+    """Get a mapping of identifiers (usable in cumulusci.yml) to transform classes"""
+    return {
+        cls.identifier: cls
+        for cls in [
+            CleanMetaXMLTransform,
+            NamespaceInjectionTransform,
+            RemoveFeatureParametersTransform,
+            BundleStaticResourcesTransform,
+            FindReplaceTransform,
+        ]
+    }
diff --git a/cumulusci/salesforce_api/package_zip.py b/cumulusci/salesforce_api/package_zip.py
index 1f12e159e..d5d329a74 100644
--- a/cumulusci/salesforce_api/package_zip.py
+++ b/cumulusci/salesforce_api/package_zip.py
@@ -1,23 +1,22 @@
-import functools
 import html
 import io
 import logging
 import os
 import pathlib
+import typing as T
 import zipfile
 from base64 import b64encode
 from xml.sax.saxutils import escape
 
-from cumulusci.utils import (
-    cd,
-    inject_namespace,
-    process_text_in_zipfile,
-    strip_namespace,
-    temporary_dir,
-    tokenize_namespace,
-    zip_clean_metaxml,
+from cumulusci.core.source_transforms.transforms import (
+    BundleStaticResourcesOptions,
+    BundleStaticResourcesTransform,
+    CleanMetaXMLTransform,
+    NamespaceInjectionOptions,
+    NamespaceInjectionTransform,
+    RemoveFeatureParametersTransform,
+    SourceTransform,
 )
-from cumulusci.utils.xml import metadata_tree
 from cumulusci.utils.ziputils import hash_zipfile_contents
 
 INSTALLED_PACKAGE_PACKAGE_XML = """<?xml version="1.0" encoding="utf-8"?>
@@ -66,36 +65,38 @@ class BasePackageZipBuilder(object):
     def _write_file(self, path, content):
         self.zf.writestr(path, content)
 
-    def as_bytes(self):
+    def as_bytes(self) -> bytes:
         fp = self.zf.fp
         self.zf.close()
         value = fp.getvalue()
         fp.close()
         return value
 
-    def as_base64(self):
+    def as_base64(self) -> str:
         return b64encode(self.as_bytes()).decode("utf-8")
 
-    def as_hash(self):
+    def as_hash(self) -> str:
         return hash_zipfile_contents(self.zf)
 
-    def __call__(self):
+    def __call__(self) -> str:
         # for backwards compatibility
         return self.as_base64()
 
 
 class MetadataPackageZipBuilder(BasePackageZipBuilder):
-    """Build a package zip from a metadata folder. The specified
-    zipfile or path must be in Metadata API format."""
+    """Build a package zip from a metadata folder in either Metadata API or Salesforce DX format."""
+
+    transforms: T.List[SourceTransform] = []
 
     def __init__(
         self,
         *,
         path=None,
-        zf: zipfile.ZipFile = None,
+        zf: T.Optional[zipfile.ZipFile] = None,
         options=None,
         logger=None,
         name=None,
+        transforms: T.Optional[T.List[SourceTransform]] = None,
     ):
         self.options = options or {}
         self.logger = logger or DEFAULT_LOGGER
@@ -106,13 +107,25 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
             self._open_zip()
         if path is not None:
             self._add_files_to_package(path)
+        if transforms:
+            self.transforms = transforms
 
         self._process()
 
     @classmethod
-    def from_zipfile(cls, zf, *, path=None, options=None, logger=None):
+    def from_zipfile(
+        cls,
+        zf: zipfile.ZipFile,
+        *,
+        path=None,
+        options=None,
+        logger=None,
+        transforms: T.Optional[T.List[SourceTransform]] = None,
+    ):
         """Start with an existing zipfile rather than a filesystem folder."""
-        return cls(zf=zf, path=path, options=options, logger=logger)
+        return cls(
+            zf=zf, path=path, options=options, logger=logger, transforms=transforms
+        )
 
     def _add_files_to_package(self, path):
         for file_path in self._find_files_to_package(path):
@@ -125,7 +138,7 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
         Walks through all directories and files in path,
         filtering using _include_directory and _include_file
         """
-        for root, dirs, files in os.walk(path):
+        for root, _, files in os.walk(path):
             root_parts = pathlib.Path(root).relative_to(path).parts
             if self._include_directory(root_parts):
                 for f in files:
@@ -159,165 +172,40 @@ class MetadataPackageZipBuilder(BasePackageZipBuilder):
         self.zf.close()
         self.zf = zipfile.ZipFile(fp, "r")
 
-        self._process_namespace_tokens()
-        self._clean_meta_xml()
-        self._bundle_staticresources()
-        self._remove_feature_parameters()
+        transforms = []
 
-    def _process_namespace_tokens(self):
-        zipf = self.zf
-        if self.options.get("namespace_tokenize"):
-            self.logger.info(
-                f"Tokenizing namespace prefix {self.options['namespace_tokenize']}__"
-            )
-            zipf = process_text_in_zipfile(
-                zipf,
-                functools.partial(
-                    tokenize_namespace,
-                    namespace=self.options["namespace_tokenize"],
-                    logger=self.logger,
-                ),
-            )
-        if self.options.get("namespace_inject"):
-            managed = not self.options.get("unmanaged", True)
-            if managed:
-                self.logger.info(
-                    "Replacing namespace tokens from metadata with namespace prefix  "
-                    f"{self.options['namespace_inject']}__"
-                )
-            else:
-                self.logger.info(
-                    "Stripping namespace tokens from metadata for unmanaged deployment"
-                )
-            zipf = process_text_in_zipfile(
-                zipf,
-                functools.partial(
-                    inject_namespace,
-                    namespace=self.options["namespace_inject"],
-                    managed=managed,
-                    namespaced_org=self.options.get("namespaced_org", False),
-                    logger=self.logger,
-                ),
-            )
-        if self.options.get("namespace_strip"):
-            self.logger.info("Stripping namespace tokens from metadata")
-            zipf = process_text_in_zipfile(
-                zipf,
-                functools.partial(
-                    strip_namespace,
-                    namespace=self.options["namespace_strip"],
-                    logger=self.logger,
-                ),
-            )
-        self.zf = zipf
+        # User-specified transforms
+        transforms.extend(self.transforms)
 
-    def _clean_meta_xml(self):
-        if not self.options.get("clean_meta_xml", True):
-            return
-        self.logger.info(
-            "Cleaning meta.xml files of packageVersion elements for deploy"
+        # Default transforms (backwards-compatible)
+        # Namespace injection
+        transforms.append(
+            NamespaceInjectionTransform(NamespaceInjectionOptions(**self.options))
         )
-        zf = zip_clean_metaxml(self.zf)
-        self.zf.close()
-        self.zf = zf
-
-    def _bundle_staticresources(self):
+        # -meta.xml cleaning
+        if self.options.get("clean_meta_xml", True):
+            transforms.append(CleanMetaXMLTransform())
+        # Static resource bundling
         relpath = self.options.get("static_resource_path")
-        if not relpath or not os.path.exists(relpath):
-            return
-        path = os.path.realpath(relpath)
-
-        # Copy existing files to new zipfile
-        zip_dest = zipfile.ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
-        for name in self.zf.namelist():
-            if name == "package.xml":
-                package_xml = self.zf.open(name)
-            else:
-                content = self.zf.read(name)
-                zip_dest.writestr(name, content)
-
-        # Build static resource bundles and add to package
-        with temporary_dir():
-            os.mkdir("staticresources")
-            bundles = []
-            for name in os.listdir(path):
-                bundle_relpath = os.path.join(relpath, name)
-                bundle_path = os.path.join(path, name)
-                if not os.path.isdir(bundle_path):
-                    continue
-                self.logger.info(
-                    "Zipping {} to add to staticresources".format(bundle_relpath)
+        if relpath and os.path.exists(relpath):
+            transforms.append(
+                BundleStaticResourcesTransform(
+                    BundleStaticResourcesOptions(static_resource_path=relpath)
                 )
-
-                # Add resource-meta.xml file
-                meta_name = "{}.resource-meta.xml".format(name)
-                meta_path = os.path.join(path, meta_name)
-                with open(meta_path, "rb") as f:
-                    zip_dest.writestr("staticresources/{}".format(meta_name), f.read())
-
-                # Add bundle
-                zip_path = os.path.join("staticresources", "{}.resource".format(name))
-                with open(zip_path, "wb") as bundle_fp:
-                    bundle_zip = zipfile.ZipFile(bundle_fp, "w", zipfile.ZIP_DEFLATED)
-                    with cd(bundle_path):
-                        for root, dirs, files in os.walk("."):
-                            for f in files:
-                                resource_file = os.path.join(root, f)
-                                bundle_zip.write(resource_file)
-                    bundle_zip.close()
-                zip_dest.write(zip_path)
-                bundles.append(name)
-
-        # Update package.xml
-        Package = metadata_tree.parse(package_xml)
-        sections = Package.findall("types", name="StaticResource")
-        section = sections[0] if sections else None
-        if not section:
-            section = Package.append("types")
-            section.append("name", text="StaticResource")
-        for name in bundles:
-            section.insert_before(section.find("name"), tag="members", text=name)
-        package_xml = Package.tostring(xml_declaration=True)
-        zip_dest.writestr("package.xml", package_xml)
-
-        self.zf.close()
-        self.zf = zip_dest
-
-    def _remove_feature_parameters(self):
-        # Remove feature parameters from unlocked packages only
-        if self.options.get("package_type") != "Unlocked":
-            return
-
-        # Copy existing files to new zipfile
-        package_xml = None
-        zip_dest = zipfile.ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
-        for name in self.zf.namelist():
-            if name == "package.xml":
-                package_xml = self.zf.open(name)
-            elif name.startswith("featureParameters/"):
-                # skip feature parameters
-                self.logger.info(f"Skipping {name} in unlocked package")
-                continue
-            else:
-                content = self.zf.read(name)
-                zip_dest.writestr(name, content)
-
-        # Remove from package.xml
-        if package_xml is not None:
-            Package = metadata_tree.parse(package_xml)
-            for mdtype in (
-                "FeatureParameterInteger",
-                "FeatureParameterBoolean",
-                "FeatureParameterDate",
-            ):
-                section = Package.find("types", name=mdtype)
-                if section is not None:
-                    Package.remove(section)
-            package_xml = Package.tostring(xml_declaration=True)
-            zip_dest.writestr("package.xml", package_xml)
-
-        self.zf.close()
-        self.zf = zip_dest
+            )
+        # Feature Parameter stripping (Unlocked Packages only)
+        if self.options.get("package_type") == "Unlocked":
+            transforms.append(RemoveFeatureParametersTransform())
+
+        for t in transforms:
+            new_zipfile = t.process(self.zf, self.logger)
+            if new_zipfile != self.zf:
+                # Ensure that zipfiles are closed (in case they're filesystem resources)
+                try:
+                    self.zf.close()
+                except ValueError:  # Attempt to close a closed ZF (on Windows)
+                    pass
+                self.zf = new_zipfile
 
 
 class CreatePackageZipBuilder(BasePackageZipBuilder):
diff --git a/cumulusci/tasks/salesforce/Deploy.py b/cumulusci/tasks/salesforce/Deploy.py
index 607a20674..a93e3c342 100644
--- a/cumulusci/tasks/salesforce/Deploy.py
+++ b/cumulusci/tasks/salesforce/Deploy.py
@@ -1,8 +1,14 @@
 import pathlib
-from typing import Optional
+from typing import List, Optional
+
+from pydantic import ValidationError
 
 from cumulusci.core.exceptions import TaskOptionsError
 from cumulusci.core.sfdx import convert_sfdx_source
+from cumulusci.core.source_transforms.transforms import (
+    SourceTransform,
+    SourceTransformList,
+)
 from cumulusci.core.utils import process_bool_arg, process_list_arg
 from cumulusci.salesforce_api.metadata import ApiDeploy
 from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
@@ -45,10 +51,15 @@ class Deploy(BaseSalesforceMetadataApiTask):
         "clean_meta_xml": {
             "description": "Defaults to True which strips the <packageVersions/> element from all meta.xml files.  The packageVersion element gets added automatically by the target org and is set to whatever version is installed in the org.  To disable this, set this option to False"
         },
+        "transforms": {
+            "description": "Apply source transforms before deploying. See the CumulusCI documentation for details on how to specify transforms."
+        },
     }
 
     namespaces = {"sf": "http://soap.sforce.com/2006/04/metadata"}
 
+    transforms: List[SourceTransform] = []
+
     def _init_options(self, kwargs):
         super(Deploy, self)._init_options(kwargs)
 
@@ -76,6 +87,17 @@ class Deploy(BaseSalesforceMetadataApiTask):
             or self.project_config.project__package__namespace
         )
 
+        if "transforms" in self.options:
+            try:
+                self.transforms = SourceTransformList.parse_obj(
+                    self.options["transforms"]
+                ).as_transforms()
+            except ValidationError as e:
+                raise TaskOptionsError(
+                    "The transform spec is not valid. See CumulusCI documentation for details of how to specify transforms. "
+                    f"The validation error was {str(e)}"
+                )
+
     def _get_api(self, path=None):
         if not path:
             path = self.options.get("path")
@@ -106,7 +128,7 @@ class Deploy(BaseSalesforceMetadataApiTask):
             return process_bool_arg(self.options.get("namespaced_org", False))
         return bool(ns) and ns == self.org_config.namespace
 
-    def _get_package_zip(self, path):
+    def _get_package_zip(self, path) -> Optional[str]:
         assert path, f"Path should be specified for {self.__class__.name}"
         if not pathlib.Path(path).exists():
             self.logger.warning(f"{path} not found.")
@@ -124,15 +146,19 @@ class Deploy(BaseSalesforceMetadataApiTask):
         package_zip = None
         with convert_sfdx_source(path, None, self.logger) as src_path:
             package_zip = MetadataPackageZipBuilder(
-                path=src_path, options=options, logger=self.logger
+                path=src_path,
+                options=options,
+                logger=self.logger,
+                transforms=self.transforms,
             )
 
+        # If the package is empty, do nothing.
         if not package_zip.zf.namelist():
             return
         return package_zip.as_base64()
 
     def freeze(self, step):
-        steps = super(Deploy, self).freeze(step)
+        steps = super().freeze(step)
         for step in steps:
             if step["kind"] == "other":
                 step["kind"] = "metadata"
diff --git a/docs/config.md b/docs/config.md
index 9ebe71654..88145d343 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -599,6 +599,18 @@ in your cumulusci.yml file.
 
 ## Advanced Configurations
 
+### Customizing Metadata Deployment
+
+CumulusCI's `deploy` task offers deep flexibility to customize your deployment process. Review [](deploy) for an in-depth guide.
+
+```{toctree}
+---
+maxdepth: 1
+---
+
+deploy
+```
+
 ### Reference Task Return Values
 
 ```{attention}
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 000000000..6a205ea2a
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,120 @@
+# Configure Metadata Deployment
+
+CumulusCI's `deploy` task uses the Metadata API to deploy metadata from the repository to a Salesforce org. `deploy` offers multiple sophisticated capabilities to suit the needs of your project.
+
+## Specifying the Deploying Package and Test Run Levels
+
+Use the `path` option to specify the path to the metadata you wish to deploy. The metadata may be in either Metadata API or Salesforce DX source format. SFDX-format source will automatically be converted to Metadata API for deployment. This conversion takes place prior to application of transforms (below) and requires that the path be listed as a package directory in `sfdx-project.json`.
+
+Set the test run level with the `test_level` option. Available values are `NoTestRun`, `RunLocalTests`, `RunAllTestsInOrg`, and `RunSpecifiedTests`. If you use `RunSpecifiedTests`, you must also supply a list of tests with the `specified_tests` option. This option accept a comma-separated value at the command line or a list in your `cumulusci.yml` markup.
+
+## Source Transforms
+
+`deploy` allows you to specify _transforms_ that run against your metadata before it is delivered to the Salesforce platform. Some of these transforms are built-in, and others you can specify in your `cumulusci.yml` to suit your project's specific needs.
+
+### Specifying Transforms
+
+Four of the `deploy` transformations are built-in, and controlled by task options (see below). Others, including the flexible `find_replace` transform, are specified by your customization in `cumulusci.yml`.
+
+You can specify transforms to run everywhere `deploy` is invoked by using customization in your `tasks` section:
+
+```yaml
+tasks:
+    deploy:
+        options:
+            transforms:
+                - transform: find_replace
+                  options:
+                      patterns:
+                          - find: foo
+                            replace: bar
+```
+
+You can also add a transform for just a specific flow step:
+
+```yaml
+flows:
+    my_flow:
+        steps:
+            1:
+                task: deploy
+                options:
+                    transforms:
+                        - transform: find_replace
+                          options:
+                              patterns:
+                                  - find: foo
+                                    replace: bar
+```
+
+or override the options in an out-of-the-box flow:
+
+```yaml
+flows:
+    deploy_packaging:
+        options:
+            deploy:
+                transforms:
+                    - transform: find_replace
+                      options:
+                          patterns:
+                              - find: foo
+                                replace: bar
+```
+
+To learn more about customization, see [](config). To discover the available transform options, consult the section for each transform in [](deploy).
+
+### Namespace Injection
+
+The `deploy` task, like many others in CumulusCI, supports _namespace injection_. This strategy allows you to flexibly inject your package's namespace into (or remove it from) your metadata to suit various deployment contexts. See [](namespace-injection) for more information about namespace injection.
+
+In most cases, you don't need to customize `deploy` options for namespace injection, as the task will automatically infer the appropriate strategy. If you do find an edge case where inference does not suit your needs, you can configure namespace injection using the following options.
+
+-   `namespace_inject`: set to specify the namespace to inject against tokens.
+-   `namespace_strip`: set to specify the namespace prefix to remove.
+-   `unmanaged`: set to `True` to replace namespace tokens with the empty string.
+-   `namespaced_org`: set to `True` to replace the special `NAMESPACED_ORG` tokens with the namespace. Note that the packaging org is considered a namespaced-org context.
+
+### Meta-XML Cleaning
+
+`deploy` can automatically remove references (`<packageVersions/>` elements) to the versions of other managed packages from `*-meta.xml` files in your metadata. This cleaning is controlled by the `clean_meta_xml` option, which defaults to `True`.
+
+### Static Resource Bundling
+
+For products that are in Metadata API source format, `deploy`, can automatically Static Resource content that is stored uncompressed in an alternate directory into the ZIP files expected by the platform. To use this capability, set the option `static_resource_path` to the path where you store unzipped Static Resource content. CumulusCI will ZIP each subdirectory and place the archives in the `staticresources` directory in the deployment bundle. You must include required `-meta.xml` files in the `static_resource_path`.
+
+### Feature-Parameter Cleaning for Unlocked Packages
+
+Unlocked Packages do not support Feature Parameter metadata. When CumulusCI is building an Unlocked Package, this transform will automatically remove Feature Parameter metadata from the deployment bundle.
+
+### Find-and-Replace Variable Injection
+
+CumulusCI allows you to specify arbitrary injections of data against tokens you define in your metadata. This capability is often used, for example, to inject secure keys or tokens from environment variables.
+
+Configure injection with the `find_replace` transform like this:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - find: foo
+                    replace: bar
+```
+
+To use an environment variable as the source of the value to replace, use the `replace_env` key. Note that it's valid to use multiple runs of `find_replace`; they will be applied in sequence.
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - find: AUTH_TOKEN
+                    replace_env: SECURE_ACCESS_KEY
+                  - find: foo
+                    replace: bar
+```


Test patch:
diff --git a/cumulusci/core/source_transforms/__init__.py b/cumulusci/core/source_transforms/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/cumulusci/core/source_transforms/tests/test_transforms.py b/cumulusci/core/source_transforms/tests/test_transforms.py
new file mode 100644
index 000000000..19912a223
--- /dev/null
+++ b/cumulusci/core/source_transforms/tests/test_transforms.py
@@ -0,0 +1,530 @@
+import io
+import os
+import typing as T
+import zipfile
+from pathlib import Path, PurePosixPath
+from unittest import mock
+from zipfile import ZipFile
+
+import pytest
+from pydantic import ValidationError
+
+from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.core.source_transforms.transforms import (
+    CleanMetaXMLTransform,
+    FindReplaceTransform,
+    FindReplaceTransformOptions,
+    NamespaceInjectionTransform,
+    RemoveFeatureParametersTransform,
+    SourceTransformList,
+    SourceTransformSpec,
+)
+from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
+from cumulusci.utils import temporary_dir
+
+
+class ZipFileSpec:
+    content: T.Dict[Path, T.Union[str, bytes, "ZipFileSpec"]]
+
+    def __init__(self, content: T.Dict[Path, T.Union[str, bytes, "ZipFileSpec"]]):
+        self.content = content
+
+    def as_zipfile(self) -> ZipFile:
+        zip_dest = ZipFile(io.BytesIO(), "w", zipfile.ZIP_DEFLATED)
+        for path, content in self.content.items():
+            if isinstance(content, ZipFileSpec):
+                raise Exception("Generating nested zipfiles is not supported")
+            zip_dest.writestr(str(path), content)
+
+        # Zipfile must be returned in open state to be used by MetadataPackageZipBuilder
+        return zip_dest
+
+    def __eq__(self, other):
+        # When we write zipfiles, Windows paths are normalized to POSIX paths.
+        # But when we read, we must construct and supply the POSIX path -
+        # Windows-style paths _aren't_ normalized on read.
+
+        if isinstance(other, ZipFileSpec):
+            return other.content == self.content
+        elif isinstance(other, ZipFile):
+            fp = other.fp
+            other.close()
+            other = ZipFile(fp, "r")  # type: ignore
+
+            def element_equal(this, other):
+                if isinstance(this, ZipFileSpec):
+                    outcome = this == ZipFile(
+                        io.BytesIO(other), "r", zipfile.ZIP_DEFLATED
+                    )
+                elif isinstance(this, str):
+                    outcome = this.encode("utf-8") == other
+                else:
+                    outcome = this == other
+
+                if not outcome:
+                    print(f"Found zipfile members unequal: {this}, {other}")
+                return outcome
+
+            return set(other.namelist()) == set(
+                [str(PurePosixPath(s)) for s in self.content.keys()]
+            ) and all(
+                element_equal(self.content[name], other.read(str(PurePosixPath(name))))
+                for name in self.content.keys()
+            )
+        else:
+            return False
+
+
+def test_namespace_inject():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path(
+                    "___NAMESPACE___Foo.cls"
+                ): "System.debug('%%%NAMESPACE%%%blah%%%NAMESPACED_ORG%%%');"
+            }
+        ).as_zipfile(),
+        options={"namespace_inject": "ns", "unmanaged": False},
+    )
+    assert ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}) == builder.zf
+
+
+def test_namespace_inject__unmanaged():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {Path("___NAMESPACE___Foo.cls"): "System.debug('%%%NAMESPACE%%%blah');"}
+        ).as_zipfile(),
+        options={"namespace_inject": "ns"},
+    )
+    assert ZipFileSpec({Path("Foo.cls"): "System.debug('blah');"}) == builder.zf
+
+
+def test_namespace_inject__namespaced_org():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path(
+                    "___NAMESPACE___Foo.cls"
+                ): "System.debug('%%%NAMESPACED_ORG%%%blah');"
+            }
+        ).as_zipfile(),
+        options={"namespace_inject": "ns", "namespaced_org": True},
+    )
+    assert ZipFileSpec({Path("Foo.cls"): "System.debug('ns__blah');"}) == builder.zf
+
+
+def test_namespace_strip():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}).as_zipfile(),
+        options={"namespace_strip": "ns", "unmanaged": False},
+    )
+    assert ZipFileSpec({Path("Foo.cls"): "System.debug('blah');"}) == builder.zf
+
+
+def test_namespace_tokenize():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec({Path("ns__Foo.cls"): "System.debug('ns__blah');"}).as_zipfile(),
+        options={"namespace_tokenize": "ns", "unmanaged": False},
+    )
+    assert (
+        ZipFileSpec(
+            {Path("___NAMESPACE___Foo.cls"): "System.debug('%%%NAMESPACE%%%blah');"}
+        )
+        == builder.zf
+    )
+
+
+def test_namespace_injection_ignores_binary():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("ns__Foo.cls"): "System.debug('ns__blah');",
+                Path("b.staticResource"): b"ns__\xFF\xFF",
+            }
+        ).as_zipfile(),
+        options={"namespace_tokenize": "ns", "unmanaged": False},
+    )
+    assert (
+        ZipFileSpec(
+            {
+                Path("___NAMESPACE___Foo.cls"): "System.debug('%%%NAMESPACE%%%blah');",
+                Path("b.staticResource"): b"ns__\xFF\xFF",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_clean_meta_xml():
+    xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
+    <apiVersion>56.0</apiVersion>
+    <packageVersions>
+        <majorNumber>3</majorNumber>
+        <minorNumber>11</minorNumber>
+        <namespace>npe01</namespace>
+    </packageVersions>
+</ApexClass>
+"""
+
+    xml_data_clean = """<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
+    <apiVersion>56.0</apiVersion>
+    </ApexClass>"""
+
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec({Path("classes/Foo.cls-meta.xml"): xml_data}).as_zipfile()
+    )
+    assert ZipFileSpec({Path("classes/Foo.cls-meta.xml"): xml_data_clean}) == builder.zf
+
+
+def test_clean_meta_xml__inactive():
+    xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
+    <apiVersion>56.0</apiVersion>
+    <packageVersions>
+        <majorNumber>3</majorNumber>
+        <minorNumber>11</minorNumber>
+        <namespace>npe01</namespace>
+    </packageVersions>
+</ApexClass>
+"""
+
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec({Path("classes") / "Foo.cls-meta.xml": xml_data}).as_zipfile(),
+        options={"clean_meta_xml": False},
+    )
+    assert ZipFileSpec({Path("classes") / "Foo.cls-meta.xml": xml_data}) == builder.zf
+
+
+def test_remove_feature_parameters():
+    xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>MyClass</members>
+        <name>ApexClass</name>
+    </types>
+    <types>
+        <members>blah</members>
+        <name>FeatureParameterInteger</name>
+    </types>
+    <version>43.0</version>
+</Package>"""
+
+    xml_data_clean = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>MyClass</members>
+        <name>ApexClass</name>
+    </types>
+    <version>43.0</version>
+</Package>
+"""
+
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("featureParameters") / "blah.featureParameterInteger": "foo",
+                Path("package.xml"): xml_data,
+                Path("classes") / "MyClass.cls": "blah",
+            }
+        ).as_zipfile(),
+        options={"package_type": "Unlocked"},
+    )
+    assert (
+        ZipFileSpec(
+            {
+                Path("package.xml"): xml_data_clean,
+                Path("classes") / "MyClass.cls": "blah",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_remove_feature_parameters__inactive():
+    xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>MyClass</members>
+        <name>ApexClass</name>
+    </types>
+    <types>
+        <members>blah</members>
+        <name>FeatureParameterInteger</name>
+    </types>
+    <version>43.0</version>
+</Package>"""
+
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("featureParameters") / "blah.featureParameterInteger": "foo",
+                Path("package.xml"): xml_data,
+                Path("classes") / "MyClass.cls": "blah",
+            }
+        ).as_zipfile(),
+    )
+    assert (
+        ZipFileSpec(
+            {
+                Path("featureParameters") / "blah.featureParameterInteger": "foo",
+                Path("package.xml"): xml_data,
+                Path("classes") / "MyClass.cls": "blah",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_bundle_static_resources():
+    xml_data = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>MyClass</members>
+        <name>ApexClass</name>
+    </types>
+    <version>43.0</version>
+</Package>
+"""
+
+    xml_data_with_statics = """<?xml version="1.0" encoding="UTF-8"?>
+<Package xmlns="http://soap.sforce.com/2006/04/metadata">
+    <fullName>TestPackage</fullName>
+    <types>
+        <members>MyClass</members>
+        <name>ApexClass</name>
+    </types>
+    <types>
+        <members>bar</members>
+        <members>foo</members>
+        <name>StaticResource</name>
+    </types>
+    <version>43.0</version>
+</Package>
+"""
+
+    with temporary_dir() as td_path:
+        # Construct a directory with zippable Static Resource data
+        # Because of how the static resource bundling works, this needs
+        # to be a real filesystem directory.
+
+        td = Path(td_path)
+
+        statics_dir = td / "statics"
+        statics_dir.mkdir()
+        (statics_dir / "foo.resource-meta.xml").write_text("foo")
+        (statics_dir / "bar.resource-meta.xml").write_text("bar")
+        (statics_dir / "foo").mkdir()
+        (statics_dir / "foo" / "foo.html").write_text("foo html")
+        (statics_dir / "bar").mkdir()
+        (statics_dir / "bar" / "bar.html").write_text("bar html")
+
+        builder = MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path("package.xml"): xml_data,
+                    Path("classes") / "MyClass.cls": "blah",
+                }
+            ).as_zipfile(),
+            options={"static_resource_path": str(statics_dir)},
+        )
+
+        foo_spec = ZipFileSpec(
+            {
+                Path("foo.html"): "foo html",
+            }
+        )
+        bar_spec = ZipFileSpec({Path("bar.html"): "bar html"})
+        compare_spec = ZipFileSpec(
+            {
+                Path("staticresources") / "foo.resource": foo_spec,
+                Path("staticresources") / "foo.resource-meta.xml": "foo",
+                Path("staticresources") / "bar.resource": bar_spec,
+                Path("staticresources") / "bar.resource-meta.xml": "bar",
+                Path("package.xml"): xml_data_with_statics,
+                Path("classes") / "MyClass.cls": "blah",
+            }
+        )
+
+        # Close and reopen the zipfile to avoid issues on Windows.
+        fp = builder.zf.fp  # type: ignore
+        builder.zf.close()  # type: ignore
+        zf = ZipFile(fp, "r")  # type: ignore
+
+        assert compare_spec == zf
+
+
+def test_find_replace_static():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("Foo.cls"): "System.debug('blah');",
+            }
+        ).as_zipfile(),
+        transforms=[
+            FindReplaceTransform(
+                FindReplaceTransformOptions.parse_obj(
+                    {"patterns": [{"find": "bl", "replace": "ye"}]}
+                )
+            )
+        ],
+    )
+
+    assert (
+        ZipFileSpec(
+            {
+                Path("Foo.cls"): "System.debug('yeah');",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_find_replace_environ():
+    with mock.patch.dict(os.environ, {"INSERT_TEXT": "ye"}):
+        builder = MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path("Foo.cls"): "System.debug('blah');",
+                }
+            ).as_zipfile(),
+            transforms=[
+                FindReplaceTransform(
+                    FindReplaceTransformOptions.parse_obj(
+                        {"patterns": [{"find": "bl", "replace_env": "INSERT_TEXT"}]}
+                    )
+                )
+            ],
+        )
+
+        assert (
+            ZipFileSpec(
+                {
+                    Path("Foo.cls"): "System.debug('yeah');",
+                }
+            )
+            == builder.zf
+        )
+
+
+def test_find_replace_environ__not_found():
+    assert "INSERT_TEXT" not in os.environ
+    with pytest.raises(TaskOptionsError):
+        MetadataPackageZipBuilder.from_zipfile(
+            ZipFileSpec(
+                {
+                    Path("Foo.cls"): "System.debug('blah');",
+                }
+            ).as_zipfile(),
+            transforms=[
+                FindReplaceTransform(
+                    FindReplaceTransformOptions.parse_obj(
+                        {"patterns": [{"find": "bl", "replace_env": "INSERT_TEXT"}]}
+                    )
+                )
+            ],
+        )
+
+
+def test_find_replace_filtered():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('blah');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        ).as_zipfile(),
+        transforms=[
+            FindReplaceTransform(
+                FindReplaceTransformOptions.parse_obj(
+                    {
+                        "patterns": [
+                            {"find": "bl", "replace": "ye", "paths": ["classes"]}
+                        ]
+                    }
+                )
+            )
+        ],
+    )
+
+    assert (
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('yeah');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_find_replace_multiple():
+    builder = MetadataPackageZipBuilder.from_zipfile(
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('blah');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        ).as_zipfile(),
+        transforms=[
+            FindReplaceTransform(
+                FindReplaceTransformOptions.parse_obj(
+                    {
+                        "patterns": [
+                            {"find": "bl", "replace": "ye", "paths": ["classes"]},
+                            {"find": "ye", "replace": "ha"},
+                        ]
+                    }
+                )
+            )
+        ],
+    )
+
+    assert (
+        ZipFileSpec(
+            {
+                Path("classes") / "Foo.cls": "System.debug('haah');",
+                Path("Bar.cls"): "System.debug('blah');",
+            }
+        )
+        == builder.zf
+    )
+
+
+def test_source_transform_parsing():
+    tl = SourceTransformList.parse_obj(
+        [
+            "clean_meta_xml",
+            {"transform": "inject_namespace", "options": {"namespace_tokenize": "foo"}},
+            {"transform": "remove_feature_parameters"},
+        ]
+    )
+
+    assert len(tl.__root__) == 3
+    assert isinstance(tl.__root__[0], SourceTransformSpec)
+    assert isinstance(tl.__root__[1], SourceTransformSpec)
+    assert tl.__root__[1].parsed_options() is not None
+    assert isinstance(tl.__root__[2], SourceTransformSpec)
+    assert tl.__root__[2].parsed_options() is None
+
+    tf = tl.as_transforms()
+
+    assert isinstance(tf[0], CleanMetaXMLTransform)
+    assert isinstance(tf[1], NamespaceInjectionTransform)
+    assert isinstance(tf[2], RemoveFeatureParametersTransform)
+    assert tf[1].options.namespace_tokenize == "foo"
+
+
+def test_source_transform_parsing__bad_transform():
+    with pytest.raises(ValidationError) as e:
+        SourceTransformList.parse_obj(
+            [
+                "destroy_the_things",
+            ]
+        )
+
+        assert "destroy_the_things is not valid" in str(e)
diff --git a/cumulusci/tasks/salesforce/tests/test_Deploy.py b/cumulusci/tasks/salesforce/tests/test_Deploy.py
index c9cfd1cf8..eee661b79 100644
--- a/cumulusci/tasks/salesforce/tests/test_Deploy.py
+++ b/cumulusci/tasks/salesforce/tests/test_Deploy.py
@@ -7,6 +7,7 @@ import pytest
 
 from cumulusci.core.exceptions import TaskOptionsError
 from cumulusci.core.flowrunner import StepSpec
+from cumulusci.core.source_transforms.transforms import CleanMetaXMLTransform
 from cumulusci.tasks.salesforce import Deploy
 from cumulusci.utils import temporary_dir, touch
 
@@ -167,6 +168,30 @@ class TestDeploy:
                 },
             )
 
+    def test_init_options__transforms(self):
+        d = create_task(
+            Deploy,
+            {
+                "path": "src",
+                "transforms": ["clean_meta_xml"],
+            },
+        )
+
+        assert len(d.transforms) == 1
+        assert isinstance(d.transforms[0], CleanMetaXMLTransform)
+
+    def test_init_options__bad_transforms(self):
+        with pytest.raises(TaskOptionsError) as e:
+            create_task(
+                Deploy,
+                {
+                    "path": "src",
+                    "transforms": [{}],
+                },
+            )
+
+            assert "transform spec is not valid" in str(e)
+
     def test_freeze_sets_kind(self):
         task = create_task(
             Deploy,