Repository: SFDO-Tooling/CumulusCI
Pull request #3386 (2022-10-07 17:29:23)

Commit-status dependency resolution falls back to main branch
W-11680730.

This PR revises the dependency resolvers used for commit-status builds (2GP and Unlocked Package parallel testing) to allow resolution to "fall back" from release branches like `feature/240` to the repository's main branch.


Patch:
diff --git a/cumulusci/core/config/project_config.py b/cumulusci/core/config/project_config.py
index d115a32df..1ee737a6d 100644
--- a/cumulusci/core/config/project_config.py
+++ b/cumulusci/core/config/project_config.py
@@ -10,6 +10,8 @@ from itertools import chain
 from pathlib import Path
 from typing import Optional, Union
 
+from github3.repos.repo import Repository
+
 from cumulusci.core.config.base_config import BaseConfig
 from cumulusci.core.versions import PackageVersionNumber
 
@@ -520,7 +522,7 @@ class BaseProjectConfig(BaseTaskFlowConfig, ProjectConfigPropertiesMixin):
             )
 
     @catch_common_github_auth_errors
-    def get_repo_from_url(self, url):
+    def get_repo_from_url(self, url) -> Optional[Repository]:
         owner, name = split_repo_url(url)
         return self.get_github_api(url).repository(owner, name)
 
diff --git a/cumulusci/core/dependencies/resolvers.py b/cumulusci/core/dependencies/resolvers.py
index 448839527..f8f845bac 100644
--- a/cumulusci/core/dependencies/resolvers.py
+++ b/cumulusci/core/dependencies/resolvers.py
@@ -1,9 +1,12 @@
 import abc
 import itertools
 from enum import Enum
-from typing import Callable, Iterable, List, Optional, Tuple
+from typing import Callable, Iterable, List, Optional, Tuple, Union
 
 from github3.exceptions import NotFoundError
+from github3.repos.branch import Branch
+from github3.repos.commit import RepoCommit, ShortCommit
+from github3.repos.repo import Repository
 
 from cumulusci.core.config.project_config import BaseProjectConfig
 from cumulusci.core.dependencies.dependencies import (
@@ -26,7 +29,6 @@ from cumulusci.core.dependencies.github import (
 from cumulusci.core.exceptions import CumulusCIException, DependencyResolutionError
 from cumulusci.core.github import (
     find_latest_release,
-    find_repo_commit_status_context,
     find_repo_feature_prefix,
     get_version_id_from_commit,
 )
@@ -46,15 +48,17 @@ class DependencyResolutionStrategy(str, Enum):
     COMMIT_STATUS_EXACT_BRANCH = "commit_status_exact_branch"
     COMMIT_STATUS_RELEASE_BRANCH = "commit_status_release_branch"
     COMMIT_STATUS_PREVIOUS_RELEASE_BRANCH = "commit_status_previous_release_branch"
+    COMMIT_STATUS_DEFAULT_BRANCH = "commit_status_default_branch"
     UNLOCKED_EXACT_BRANCH = "unlocked_exact_branch"
     UNLOCKED_RELEASE_BRANCH = "unlocked_release_branch"
     UNLOCKED_PREVIOUS_RELEASE_BRANCH = "unlocked_previous_release_branch"
+    UNLOCKED_DEFAULT_BRANCH = "unlocked_default_branch"
     BETA_RELEASE_TAG = "latest_beta"
     RELEASE_TAG = "latest_release"
     UNMANAGED_HEAD = "unmanaged"
 
 
-class Resolver(abc.ABC):
+class AbstractResolver(abc.ABC):
     """Abstract base class for dependency resolution strategies."""
 
     name = "Resolver"
@@ -73,7 +77,7 @@ class Resolver(abc.ABC):
         return self.name
 
 
-class GitHubTagResolver(Resolver):
+class GitHubTagResolver(AbstractResolver):
     """Resolver that identifies a ref by a specific GitHub tag."""
 
     name = "GitHub Tag Resolver"
@@ -125,7 +129,7 @@ class GitHubTagResolver(Resolver):
             raise DependencyResolutionError(f"No release found for tag {dep.tag}")
 
 
-class GitHubReleaseTagResolver(Resolver):
+class GitHubReleaseTagResolver(AbstractResolver):
     """Resolver that identifies a ref by finding the latest GitHub release."""
 
     name = "GitHub Release Resolver"
@@ -184,7 +188,7 @@ class GitHubBetaReleaseTagResolver(GitHubReleaseTagResolver):
     include_beta = True
 
 
-class GitHubUnmanagedHeadResolver(Resolver):
+class GitHubUnmanagedHeadResolver(AbstractResolver):
     """Resolver that identifies a ref by finding the latest commit on the main branch."""
 
     name = "GitHub Unmanaged Resolver"
@@ -199,71 +203,130 @@ class GitHubUnmanagedHeadResolver(Resolver):
         return (repo.branch(repo.default_branch).commit.sha, None)
 
 
-class GitHubReleaseBranchResolver(Resolver, abc.ABC):
-    """Abstract base class for resolvers that use commit statuses on release branches to find refs."""
+def get_release_id(context: BaseProjectConfig) -> int:
+    """Detect a release id (like NNN in feature/NNN__some_branch)
+    in the current branch and return it as an integer."""
+    if not context.repo_branch or not context.project__git__prefix_feature:
+        raise DependencyResolutionError(
+            "Cannot get current branch or feature branch prefix"
+        )
+    release_id = get_release_identifier(
+        context.repo_branch, context.project__git__prefix_feature
+    )
+    if not release_id:
+        raise DependencyResolutionError("Cannot get current release identifier")
+
+    return int(release_id)
+
+
+def locate_commit_status_package_id(
+    remote_repo: Repository, release_branch: Branch, context_2gp: str
+) -> Tuple[Optional[str], Optional[Union[RepoCommit, ShortCommit]]]:
+    """Given a branch on a remote repo, walk the first 5 commits looking
+    for a commit status equal to context_2gp and attempt to parse a
+    package version id from the commit status detail."""
+    version_id = None
+    count = 0
+    commit = release_branch.commit
+    while version_id is None and count < 5:
+        version_id = get_version_id_from_commit(remote_repo, commit.sha, context_2gp)
+        if version_id:
+            break
+        count += 1
+        if commit.parents:
+            commit = remote_repo.commit(commit.parents[0]["sha"])
+        else:
+            commit = None
+            break
 
-    commit_status_context = "2gp_context"
-    commit_status_default = "Build Feature Test Package"
+    return version_id, commit
+
+
+def get_remote_context(
+    repo: Repository, commit_status_context: str, default_context: str
+) -> str:
+    config = get_remote_project_config(repo, repo.default_branch)
+    return config.lookup(f"project__git__{commit_status_context}") or default_context
+
+
+class AbstractGitHubCommitStatusPackageResolver(AbstractResolver, abc.ABC):
+    """Abstract base class for resolvers that use commit statuses to find packages."""
+
+    commit_status_context = ""
+    commit_status_default = ""
 
     def can_resolve(self, dep: DynamicDependency, context: BaseProjectConfig) -> bool:
         return self.is_valid_repo_context(context) and isinstance(
             dep, BaseGitHubDependency
         )
 
-    def get_release_id(self, context: BaseProjectConfig) -> int:
-        if not context.repo_branch or not context.project__git__prefix_feature:
-            raise DependencyResolutionError(
-                "Cannot get current branch or feature branch prefix"
-            )
-        release_id = get_release_identifier(
-            context.repo_branch, context.project__git__prefix_feature
-        )
-        if not release_id:
-            raise DependencyResolutionError("Cannot get current release identifier")
+    def is_valid_repo_context(self, context: BaseProjectConfig) -> bool:
+        return bool(context.repo_branch and context.project__git__prefix_feature)
+
+    @abc.abstractmethod
+    def get_branches(
+        self,
+        dep: BaseGitHubDependency,
+        context: BaseProjectConfig,
+    ) -> List[Branch]:
+        ...
 
-        return int(release_id)
+    def resolve(
+        self, dep: BaseGitHubDependency, context: BaseProjectConfig
+    ) -> Tuple[Optional[str], Optional[StaticDependency]]:
+        branches = self.get_branches(dep, context)
 
-    def is_valid_repo_context(self, context) -> bool:
-        return (
-            context.repo_branch
-            and context.project__git__prefix_feature
-            and is_release_branch_or_child(
-                context.repo_branch, context.project__git__prefix_feature
-            )
+        # We know `repo` is not None because `get_branches()` will raise in that case.
+        repo = context.get_repo_from_url(dep.github)
+        remote_context = get_remote_context(
+            repo, self.commit_status_context, self.commit_status_default
         )
-
-    def locate_commit_status_package_id(self, remote_repo, release_branch, context_2gp):
-        version_id = None
-        count = 0
-        commit = release_branch.commit
-        while version_id is None and count < 5:
-            version_id = get_version_id_from_commit(
-                remote_repo, commit.sha, context_2gp
+        for branch in branches:
+            version_id, commit = locate_commit_status_package_id(
+                repo,
+                branch,
+                remote_context,
             )
-            if version_id:
-                break
-            count += 1
-            if commit.parents:
-                commit = remote_repo.commit(commit.parents[0]["sha"])
-            else:
-                commit = None
-                break
 
-        return version_id, commit
+            if version_id and commit:
+                context.logger.info(
+                    f"{self.name} located package version {version_id} on branch {branch.name} on {repo.clone_url} at commit {branch.commit.sha}"
+                )
+                package_config = get_remote_project_config(repo, commit.sha)
+                package_name, _ = get_package_data(package_config)
+
+                return commit.sha, PackageVersionIdDependency(
+                    version_id=version_id, package_name=package_name
+                )
 
+        context.logger.warn(
+            f"{self.name} did not locate package package version on {repo.clone_url}."
+        )
+        return (None, None)
 
-class GitHubReleaseBranchCommitStatusResolver(GitHubReleaseBranchResolver):
-    """Resolver that identifies a ref by finding a beta package version
-    in a commit status on a `feature/NNN` release branch."""
 
-    name = "GitHub Release Branch Commit Status Resolver"
-    branch_depth = 1
+class AbstractGitHubReleaseBranchResolver(
+    AbstractGitHubCommitStatusPackageResolver, abc.ABC
+):
+    """Abstract base class for resolvers that use commit statuses on release branches to find refs."""
 
-    def resolve(
-        self, dep: BaseGitHubDependency, context: BaseProjectConfig
-    ) -> Tuple[Optional[str], Optional[StaticDependency]]:
+    branch_offset_start = 0
+    branch_offset_end = 0
 
-        release_id = self.get_release_id(context)
+    def is_valid_repo_context(self, context: BaseProjectConfig) -> bool:
+        return bool(
+            super().is_valid_repo_context(context)
+            and is_release_branch_or_child(
+                context.repo_branch, context.project__git__prefix_feature  # type: ignore
+            )
+        )
+
+    def get_branches(
+        self,
+        dep: BaseGitHubDependency,
+        context: BaseProjectConfig,
+    ) -> List[Branch]:
+        release_id = get_release_id(context)
         repo = context.get_repo_from_url(dep.github)
         if not repo:
             raise DependencyResolutionError(
@@ -272,88 +335,87 @@ class GitHubReleaseBranchCommitStatusResolver(GitHubReleaseBranchResolver):
 
         try:
             remote_branch_prefix = find_repo_feature_prefix(repo)
-            remote_2gp_context = find_repo_commit_status_context(
-                repo, self.commit_status_context, self.commit_status_default
-            )
         except Exception:
             context.logger.info(
                 f"Could not find feature branch prefix or commit-status context for {repo.clone_url}. Unable to resolve packages."
             )
-            return (None, None)
+            return []
 
         # We will check at least the release branch corresponding to our release id.
         # We may be configured to check backwards on release branches.
-        release_branch = None
-        for i in range(0, self.branch_depth):
+        release_branches = []
+        for i in range(self.branch_offset_start, self.branch_offset_end):
+            remote_matching_branch = construct_release_branch_name(
+                remote_branch_prefix, str(release_id - i)
+            )
             try:
-                remote_matching_branch = construct_release_branch_name(
-                    remote_branch_prefix, str(release_id - i)
-                )
-
-                release_branch = repo.branch(remote_matching_branch)
+                release_branches.append(repo.branch(remote_matching_branch))
             except NotFoundError:
+                context.logger.info(f"Remote branch {remote_matching_branch} not found")
                 pass
 
-        if release_branch:
-            version_id, commit = self.locate_commit_status_package_id(
-                repo,
-                release_branch,
-                remote_2gp_context,
-            )
+        return release_branches
 
-            if version_id:
-                context.logger.info(
-                    f"Located package version {version_id} for release {release_id} on {repo.clone_url} at commit {release_branch.commit.sha}"
-                )
-                package_config = get_remote_project_config(repo, commit.sha)
-                package_name, _ = get_package_data(package_config)
 
-                return commit.sha, PackageVersionIdDependency(
-                    version_id=version_id, package_name=package_name
-                )
+class GitHubReleaseBranchCommitStatusResolver(AbstractGitHubReleaseBranchResolver):
+    """Resolver that identifies a ref by finding a beta 2GP package version
+    in a commit status on a `feature/NNN` release branch."""
 
-        context.logger.warn(
-            f"No package version located for release {release_id} on {repo.clone_url}."
-        )
-        return (None, None)
+    name = "GitHub Release Branch Commit Status Resolver"
+    commit_status_context = "2gp_context"
+    commit_status_default = "Build Feature Test Package"
+    branch_offset_start = 0
+    branch_offset_end = 1
 
 
-class GitHubReleaseBranchUnlockedResolver(GitHubReleaseBranchCommitStatusResolver):
+class GitHubReleaseBranchUnlockedResolver(AbstractGitHubReleaseBranchResolver):
+    """Resolver that identifies a ref by finding an unlocked package version
+    in a commit status on a `feature/NNN` release branch."""
+
     name = "GitHub Release Branch Unlocked Commit Status Resolver"
     commit_status_context = "unlocked_context"
     commit_status_default = "Build Unlocked Test Package"
+    branch_offset_start = 0
+    branch_offset_end = 1
 
 
 class GitHubPreviousReleaseBranchCommitStatusResolver(
-    GitHubReleaseBranchCommitStatusResolver
+    AbstractGitHubReleaseBranchResolver
 ):
-    """Resolver that identifies a ref by finding a beta package version
+    """Resolver that identifies a ref by finding a beta 2GP package version
     in a commit status on a `feature/NNN` release branch that is earlier
     than the matching local release branch."""
 
     name = "GitHub Previous Release Branch Commit Status Resolver"
-    branch_depth = 3
+    commit_status_context = "2gp_context"
+    commit_status_default = "Build Feature Test Package"
+    branch_offset_start = 1
+    branch_offset_end = 3
 
 
-class GitHubPreviousReleaseBranchUnlockedResolver(
-    GitHubPreviousReleaseBranchCommitStatusResolver
-):
+class GitHubPreviousReleaseBranchUnlockedResolver(AbstractGitHubReleaseBranchResolver):
+    """Resolver that identifies a ref by finding an unlocked package version
+    in a commit status on a `feature/NNN` release branch that is earlier
+    than the matching local release branch."""
+
     name = "GitHub Previous Release Branch Unlocked Commit Status Resolver"
     commit_status_context = "unlocked_context"
     commit_status_default = "Build Unlocked Test Package"
+    branch_offset_start = 1
+    branch_offset_end = 3
 
 
-class GitHubReleaseBranchExactMatchCommitStatusResolver(GitHubReleaseBranchResolver):
-    """Resolver that identifies a ref by finding a beta package version
+class AbstractGitHubExactMatchCommitStatusResolver(
+    AbstractGitHubCommitStatusPackageResolver, abc.ABC
+):
+    """Abstract base class for resolvers that identify a ref by finding a package version
     in a commit status on a branch whose name matches the local branch."""
 
-    name = "GitHub Exact-Match Commit Status Resolver"
-
-    def resolve(
-        self, dep: BaseGitHubDependency, context: BaseProjectConfig
-    ) -> Tuple[Optional[str], Optional[StaticDependency]]:
-        release_id = self.get_release_id(context)
-
+    def get_branches(
+        self,
+        dep: BaseGitHubDependency,
+        context: BaseProjectConfig,
+    ) -> List[Branch]:
         repo = context.get_repo_from_url(dep.github)
         if not repo:
             raise DependencyResolutionError(
@@ -362,14 +424,11 @@ class GitHubReleaseBranchExactMatchCommitStatusResolver(GitHubReleaseBranchResol
 
         try:
             remote_branch_prefix = find_repo_feature_prefix(repo)
-            remote_2gp_context = find_repo_commit_status_context(
-                repo, self.commit_status_context, self.commit_status_default
-            )
         except Exception:
             context.logger.info(
                 f"Could not find feature branch prefix or commit-status context for {repo.clone_url}. Unable to resolve package."
             )
-            return (None, None)
+            return []
 
         # Attempt exact match
         try:
@@ -379,51 +438,74 @@ class GitHubReleaseBranchExactMatchCommitStatusResolver(GitHubReleaseBranchResol
             release_branch = repo.branch(f"{remote_branch_prefix}{branch}")
         except Exception:
             context.logger.info(f"Exact-match branch not found for {repo.clone_url}.")
-            return (None, None)
-
-        version_id, commit = self.locate_commit_status_package_id(
-            repo,
-            release_branch,
-            remote_2gp_context,
-        )
+            return []
 
-        if version_id:
-            context.logger.info(
-                f"Located package version {version_id} for release {release_id} on {repo.clone_url} at commit {release_branch.commit.sha}"
-            )
+        return [release_branch]
 
-            package_config = get_remote_project_config(repo, commit.sha)
-            package_name, _ = get_package_data(package_config)
 
-            return commit.sha, PackageVersionIdDependency(
-                version_id=version_id, package_name=package_name
-            )
+class GitHubExactMatch2GPResolver(AbstractGitHubExactMatchCommitStatusResolver):
+    """Resolver that identifies a ref by finding a 2GP package version
+    in a commit status on a branch whose name matches the local branch."""
 
-        context.logger.warn(
-            f"No package version located for release {release_id} on {repo.clone_url}."
-        )
-        return (None, None)
+    name = "GitHub Exact-Match Commit Status Resolver"
+    commit_status_context = "2gp_context"
+    commit_status_default = "Build Feature Test Package"
 
 
-class GitHubReleaseBranchExactMatchUnlockedCommitStatusResolver(
-    GitHubReleaseBranchExactMatchCommitStatusResolver
+class GitHubExactMatchUnlockedCommitStatusResolver(
+    AbstractGitHubExactMatchCommitStatusResolver
 ):
+    """Resolver that identifies a ref by finding an unlocked package version
+    in a commit status on a branch whose name matches the local branch."""
+
     name = "GitHub Exact-Match Unlocked Commit Status Resolver"
     commit_status_context = "unlocked_context"
     commit_status_default = "Build Unlocked Test Package"
 
 
+class AbstractGitHubDefaultBranchCommitStatusResolver(
+    AbstractGitHubCommitStatusPackageResolver, abc.ABC
+):
+    """Abstract base class for resolvers that identify a ref by finding a beta package version
+    in a commit status on the repo's default branch."""
+
+    def get_branches(
+        self,
+        dep: BaseGitHubDependency,
+        context: BaseProjectConfig,
+    ) -> List[Branch]:
+        repo = context.get_repo_from_url(dep.github)
+
+        return [repo.branch(repo.default_branch)]
+
+
+class GitHubDefaultBranch2GPResolver(AbstractGitHubDefaultBranchCommitStatusResolver):
+    name = "GitHub Default Branch Commit Status Resolver"
+    commit_status_context = "2gp_context"
+    commit_status_default = "Build Feature Test Package"
+
+
+class GitHubDefaultBranchUnlockedCommitStatusResolver(
+    AbstractGitHubDefaultBranchCommitStatusResolver
+):
+    name = "GitHub Default Branch Unlocked Commit Status Resolver"
+    commit_status_context = "unlocked_context"
+    commit_status_default = "Build Unlocked Test Package"
+
+
 RESOLVER_CLASSES = {
     DependencyResolutionStrategy.STATIC_TAG_REFERENCE: GitHubTagResolver,
-    DependencyResolutionStrategy.COMMIT_STATUS_EXACT_BRANCH: GitHubReleaseBranchExactMatchCommitStatusResolver,
+    DependencyResolutionStrategy.COMMIT_STATUS_EXACT_BRANCH: GitHubExactMatch2GPResolver,
     DependencyResolutionStrategy.COMMIT_STATUS_RELEASE_BRANCH: GitHubReleaseBranchCommitStatusResolver,
     DependencyResolutionStrategy.COMMIT_STATUS_PREVIOUS_RELEASE_BRANCH: GitHubPreviousReleaseBranchCommitStatusResolver,
+    DependencyResolutionStrategy.COMMIT_STATUS_DEFAULT_BRANCH: GitHubDefaultBranch2GPResolver,
     DependencyResolutionStrategy.BETA_RELEASE_TAG: GitHubBetaReleaseTagResolver,
     DependencyResolutionStrategy.RELEASE_TAG: GitHubReleaseTagResolver,
     DependencyResolutionStrategy.UNMANAGED_HEAD: GitHubUnmanagedHeadResolver,
-    DependencyResolutionStrategy.UNLOCKED_EXACT_BRANCH: GitHubReleaseBranchExactMatchUnlockedCommitStatusResolver,
+    DependencyResolutionStrategy.UNLOCKED_EXACT_BRANCH: GitHubExactMatchUnlockedCommitStatusResolver,
     DependencyResolutionStrategy.UNLOCKED_RELEASE_BRANCH: GitHubReleaseBranchUnlockedResolver,
     DependencyResolutionStrategy.UNLOCKED_PREVIOUS_RELEASE_BRANCH: GitHubPreviousReleaseBranchUnlockedResolver,
+    DependencyResolutionStrategy.UNLOCKED_DEFAULT_BRANCH: GitHubDefaultBranchUnlockedCommitStatusResolver,
 }
 
 
@@ -432,7 +514,7 @@ RESOLVER_CLASSES = {
 
 def get_resolver(
     strategy: DependencyResolutionStrategy, dependency: DynamicDependency
-) -> Optional[Resolver]:
+) -> Optional[AbstractResolver]:
     """Return an instance of a resolver class capable of applying the specified
     resolution strategy to the dependency."""
     # This will be fleshed out when further types of DynamicDependency are added.
diff --git a/cumulusci/cumulusci.yml b/cumulusci/cumulusci.yml
index 0122ff19d..c679d58fe 100644
--- a/cumulusci/cumulusci.yml
+++ b/cumulusci/cumulusci.yml
@@ -1462,11 +1462,13 @@ project:
                 - unlocked_exact_branch
                 - unlocked_release_branch
                 - unlocked_previous_release_branch
+                - unlocked_default_branch
             commit_status:
                 - tag
                 - commit_status_exact_branch
                 - commit_status_release_branch
                 - commit_status_previous_release_branch
+                - commit_status_default_branch
                 - latest_beta
                 - latest_release
                 - unmanaged
diff --git a/docs/dev.md b/docs/dev.md
index 70b7691f1..67e1870df 100644
--- a/docs/dev.md
+++ b/docs/dev.md
@@ -569,7 +569,7 @@ named, ordered lists of resolvers to apply. When CumulusCI applies a
 resolution strategy to a dependency, it applies each resolver from top
 to bottom until a resolver succeeds in resolving the dependency.
 
-Three resolution strategies are provided in the CumulusCI standard
+Four resolution strategies are provided in the CumulusCI standard
 library:
 
 > -   `latest_release`, which will attempt to resolve to the latest
@@ -577,12 +577,15 @@ library:
 > -   `include_beta`, which will attempt to resolve to the latest beta,
 >     if any, or managed release of a managed package project.
 > -   `commit_status`, which will resolve to second-generation package
->     betas created on feature branches, if any, before falling back to
->     managed package releases. This strategy is used only in the
->     `qa_org_2gp` and `ci_feature_2gp` flows.
-
-The complete list of steps taken by each resolution strategy is given
-below.
+>     betas created on feature branches, if any, or the main branch,
+>     before falling back to managed package releases. This strategy
+>     is used only in the `qa_org_2gp` and `ci_feature_2gp` flows.
+> -   `unlocked`, which will resolve to unlocked package betas
+>     created on feature branches, if any, or the main branch.
+>     This strategy does _not_ fall back to managed package releases,
+>     and is used in the `qa_org_unlocked` flow.
+>     The complete list of steps taken by each resolution strategy is given
+>     below.
 
 Each flow that resolves dependencies selects a resolution strategy that
 meets its needs. Two aliases, `production`, and `preproduction`, are
@@ -610,6 +613,38 @@ dependencies, if present.
 The standard resolution strategies execute the following steps to
 resolve a dependency:
 
+**latest_release**:
+
+This resolution strategy is suitable for any build for products that
+wish to consume production releases of their dependencies during
+development and testing. It is also suitable for production flows (such
+as `install_prod` or a MetaDeploy installer flow) for all products.
+
+-   If a `tag` is present, use the commit for that tag, and any package
+    version found there. (Resolver: `tag`)
+-   Identify the most recent production package release via the GitHub
+    Releases section. If located, use that package and commit.
+    (Resolver: `latest_release`)
+-   Use the most recent commit on the repository's main branch as an
+    unmanaged dependency. (Resolver: `unmanaged`)
+
+**include_beta**:
+
+This resolution strategy is suitable for any pre-production build for
+products that wish to consume beta releases of their dependencies during
+development and testing.
+
+-   If a `tag` is present, use the commit for that tag, and any package
+    version found there. (Resolver: `tag`)
+-   Identify the most recent beta package release via the GitHub
+    Releases section. If located, use that package and commit.
+    (Resolver: `latest_beta`)
+-   Identify the most recent production package release via the GitHub
+    Releases section. If located, use that package and commit.
+    (Resolver: `latest_release`)
+-   Use the most recent commit on the repository's main branch as an
+    unmanaged dependency. (Resolver: `unmanaged`)
+
 **commit_status**:
 
 This resolution strategy is suitable for feature builds on products that
@@ -641,6 +676,9 @@ utilize a release branch model and build second-generation package betas
 >     Id for any of the first five commits on that branch, use that
 >     commit and package. (Resolver:
 >     `commit_status_previous_release_branch`)
+> -   If a commit status contains a beta package Id for any of the first
+>     five commits on the default branch, use that commit and package.
+>     (Resolver: `commit_status_default_branch`)
 > -   Identify the most recent beta package release via the GitHub
 >     Releases section. If located, use that package and commit.
 >     (Resolver: `latest_beta`)
@@ -650,37 +688,40 @@ utilize a release branch model and build second-generation package betas
 > -   Use the most recent commit on the repository's main branch as an
 >     unmanaged dependency. (Resolver: `unmanaged`)
 
-**include_beta**:
-
-This resolution strategy is suitable for any pre-production build for
-products that wish to consume beta releases of their dependencies during
-development and testing.
+**unlocked**:
 
--   If a `tag` is present, use the commit for that tag, and any package
-    version found there. (Resolver: `tag`)
--   Identify the most recent beta package release via the GitHub
-    Releases section. If located, use that package and commit.
-    (Resolver: `latest_beta`)
--   Identify the most recent production package release via the GitHub
-    Releases section. If located, use that package and commit.
-    (Resolver: `latest_release`)
--   Use the most recent commit on the repository's main branch as an
-    unmanaged dependency. (Resolver: `unmanaged`)
-
-**latest_release**:
-
-This resolution strategy is suitable for any build for products that
-wish to consume production releases of their dependencies during
-development and testing. It is also suitable for production flows (such
-as `install_prod` or a MetaDeploy installer flow) for all products.
+This resolution strategy is suitable for feature builds on products that
+utilize a release branch model and build unlocked package betas
+(using the `build_unlocked_test_package` flow) on each commit. It is
+also suitable for use cases where a persistent org and Unlocked
+Package versions are used for ongoing QA.
 
--   If a `tag` is present, use the commit for that tag, and any package
-    version found there. (Resolver: `tag`)
--   Identify the most recent production package release via the GitHub
-    Releases section. If located, use that package and commit.
-    (Resolver: `latest_release`)
--   Use the most recent commit on the repository's main branch as an
-    unmanaged dependency. (Resolver: `unmanaged`)
+> -   If the current branch is a release branch (`feature/NNN`, where
+>     `feature/` is the feature branch prefix and `NNN` is any integer)
+>     or a child branch of a release branch, locate a branch with the
+>     same name in the dependency repository. If a commit status
+>     contains a beta package Id for any of the first five commits on
+>     that branch, use that commit and package. (Resolver:
+>     `unlocked_exact_branch`)
+> -   If the current branch is a release branch (`feature/NNN`, where
+>     `feature/` is the feature branch prefix and `NNN` is any integer)
+>     or a child branch of a release branch, locate a matching release
+>     branch (`feature/NNN`) in the dependency repository. If a commit
+>     status contains a beta package Id for any of the first five
+>     commits on that branch, use that commit and package. (Resolver:
+>     `unlocked_release_branch`)
+> -   If the current branch is a release branch (`feature/NNN`, where
+>     `feature/` is the feature branch prefix and `NNN` is any integer)
+>     or a child branch of a release branch, locate a branch for either
+>     of the two previous releases (e.g., `feature/230` in this
+>     repository would search `feature/229` and `feature/228`) in the
+>     dependency repository. If a commit status contains a beta package
+>     Id for any of the first five commits on that branch, use that
+>     commit and package. (Resolver:
+>     `unlocked_previous_release_branch`)
+> -   If a commit status contains a beta package Id for any of the first
+>     five commits on the default branch, use that commit and package.
+>     (Resolver: `unlocked_default_branch`)
 
 #### Customizing Resolution Strategies
 


Test patch:
diff --git a/cumulusci/core/config/tests/test_config.py b/cumulusci/core/config/tests/test_config.py
index 0766ad867..cd1ebfb0f 100644
--- a/cumulusci/core/config/tests/test_config.py
+++ b/cumulusci/core/config/tests/test_config.py
@@ -191,6 +191,7 @@ class DummyRepository(mock.Mock):
     def branch(self, name):
         branch = mock.Mock()
         branch.commit.sha = "commit_sha"
+        branch.name = name
         return branch
 
     def tag(self, sha):
diff --git a/cumulusci/core/dependencies/tests/conftest.py b/cumulusci/core/dependencies/tests/conftest.py
index d3021d66a..e3ec95382 100644
--- a/cumulusci/core/dependencies/tests/conftest.py
+++ b/cumulusci/core/dependencies/tests/conftest.py
@@ -217,7 +217,20 @@ def github():
         },
         releases=[DummyRelease("release/1.0", "1.0")],
         commits={
-            "main_sha": mock.Mock(sha="main_sha"),
+            "main_sha": mock.Mock(
+                sha="main_sha",
+                status=mock.Mock(
+                    return_value=mock.Mock(
+                        statuses=[
+                            mock.Mock(
+                                state="success",
+                                context="Nonstandard Package Status",
+                                description="version_id: 04t000000000005",
+                            )
+                        ]
+                    )
+                ),
+            ),
             "feature/232_sha": mock.Mock(
                 sha="feature/232_sha",
                 parents=[{"sha": "parent_sha"}],
@@ -276,7 +289,11 @@ def github():
         },
         releases=[],
         commits={
-            "main_sha": mock.Mock(sha="main_sha"),
+            "main_sha": mock.Mock(
+                sha="main_sha",
+                parents=[],
+                status=mock.Mock(return_value=mock.Mock(statuses=[])),
+            ),
             "feature/232_sha": mock.Mock(
                 sha="feature/232_sha",
                 parents=[{"sha": "parent_sha"}],
@@ -293,6 +310,7 @@ def github():
     def branch(which_repo, which_branch):
         branch = mock.Mock()
         branch.commit = which_repo.commit(f"{which_branch}_sha")
+        branch.name = which_branch
         return branch
 
     TWO_GP_REPO.branch = mock.Mock(wraps=functools.partial(branch, TWO_GP_REPO))
diff --git a/cumulusci/core/dependencies/tests/test_dependencies.py b/cumulusci/core/dependencies/tests/test_dependencies.py
index 22f1a4989..046393e2d 100644
--- a/cumulusci/core/dependencies/tests/test_dependencies.py
+++ b/cumulusci/core/dependencies/tests/test_dependencies.py
@@ -21,7 +21,10 @@ from cumulusci.core.dependencies.dependencies import (
     UnmanagedZipURLDependency,
     parse_dependency,
 )
-from cumulusci.core.dependencies.resolvers import DependencyResolutionStrategy, Resolver
+from cumulusci.core.dependencies.resolvers import (
+    AbstractResolver,
+    DependencyResolutionStrategy,
+)
 from cumulusci.core.exceptions import DependencyResolutionError
 from cumulusci.salesforce_api.package_install import (
     DEFAULT_PACKAGE_RETRY_OPTIONS,
@@ -49,7 +52,7 @@ class ConcreteDynamicDependency(DynamicDependency):
         return ""
 
 
-class MockResolver(Resolver):
+class MockResolver(AbstractResolver):
     def __init__(
         self,
         resolve_ref: Optional[str] = None,
@@ -67,7 +70,7 @@ class MockResolver(Resolver):
         return self.ref, self.dep
 
 
-class MockBadResolver(Resolver):
+class MockBadResolver(AbstractResolver):
     def can_resolve(self, dep: DynamicDependency, context: BaseProjectConfig) -> bool:
         return True
 
diff --git a/cumulusci/core/dependencies/tests/test_resolvers.py b/cumulusci/core/dependencies/tests/test_resolvers.py
index dc8203df4..acd3eed74 100644
--- a/cumulusci/core/dependencies/tests/test_resolvers.py
+++ b/cumulusci/core/dependencies/tests/test_resolvers.py
@@ -17,18 +17,21 @@ from cumulusci.core.dependencies.dependencies import (
     UnmanagedGitHubRefDependency,
 )
 from cumulusci.core.dependencies.resolvers import (
+    AbstractGitHubReleaseBranchResolver,
     DependencyResolutionStrategy,
     GitHubBetaReleaseTagResolver,
+    GitHubDefaultBranch2GPResolver,
+    GitHubExactMatch2GPResolver,
     GitHubReleaseBranchCommitStatusResolver,
-    GitHubReleaseBranchExactMatchCommitStatusResolver,
-    GitHubReleaseBranchResolver,
     GitHubReleaseTagResolver,
     GitHubTagResolver,
     GitHubUnmanagedHeadResolver,
     dependency_filter_ignore_deps,
+    get_release_id,
     get_resolver,
     get_resolver_stack,
     get_static_dependencies,
+    locate_commit_status_package_id,
 )
 from cumulusci.core.exceptions import CumulusCIException, DependencyResolutionError
 
@@ -326,7 +329,7 @@ class TestGitHubUnmanagedHeadResolver:
         assert resolver.resolve(dep, project_config) == ("commit_sha", None)
 
 
-class ConcreteGitHubReleaseBranchResolver(GitHubReleaseBranchResolver):
+class ConcreteGitHubReleaseBranchResolver(AbstractGitHubReleaseBranchResolver):
     def resolve(
         self, dep: GitHubDynamicDependency, context: BaseProjectConfig
     ) -> Tuple[Optional[str], Optional[StaticDependency]]:
@@ -365,7 +368,7 @@ class TestGitHubReleaseBranchResolver:
         pc.repo_info["branch"] = "feature/232__test"
         pc.project__git["prefix_feature"] = "feature/"
 
-        assert ConcreteGitHubReleaseBranchResolver().get_release_id(pc) == 232
+        assert get_release_id(pc) == 232
 
     def test_get_release_id__not_release_branch(self):
         pc = BaseProjectConfig(UniversalConfig())
@@ -375,7 +378,7 @@ class TestGitHubReleaseBranchResolver:
             repo_branch.return_value = None
 
             with pytest.raises(DependencyResolutionError) as e:
-                ConcreteGitHubReleaseBranchResolver().get_release_id(pc)
+                get_release_id(pc)
 
             assert "Cannot get current branch" in str(e)
 
@@ -388,13 +391,13 @@ class TestGitHubReleaseBranchResolver:
             pc.project__git["prefix_feature"] = "feature/"
 
             with pytest.raises(DependencyResolutionError) as e:
-                ConcreteGitHubReleaseBranchResolver().get_release_id(pc)
+                get_release_id(pc)
 
             assert "Cannot get current release identifier" in str(e)
 
     def test_locate_commit_status_package_id__not_found_with_parent(self, github):
         repo = github.repository("SFDO-Tooling", "TwoGPMissingRepo")
-        assert ConcreteGitHubReleaseBranchResolver().locate_commit_status_package_id(
+        assert locate_commit_status_package_id(
             repo, repo.branch("feature/232"), "Build Feature Test Package"
         ) == (None, None)
 
@@ -472,12 +475,12 @@ class TestGitHubReleaseBranchCommitStatusResolver:
         assert resolver.resolve(dep, project_config) == (None, None)
 
 
-class TestGitHubReleaseBranchExactMatchCommitStatusResolver:
+class TestGitHubExactMatch2GPResolver:
     def test_exact_branch_resolver(self, project_config):
         project_config.repo_branch = "feature/232__test"
         project_config.project__git__prefix_feature = "feature/"
 
-        resolver = GitHubReleaseBranchExactMatchCommitStatusResolver()
+        resolver = GitHubExactMatch2GPResolver()
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/TwoGPRepo"
         )
@@ -494,7 +497,7 @@ class TestGitHubReleaseBranchExactMatchCommitStatusResolver:
         project_config.repo_branch = "feature/232__test"
         project_config.project__git__prefix_feature = "feature/"
 
-        resolver = GitHubReleaseBranchExactMatchCommitStatusResolver()
+        resolver = GitHubExactMatch2GPResolver()
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/NonexistentRepo"
         )
@@ -514,7 +517,7 @@ class TestGitHubReleaseBranchExactMatchCommitStatusResolver:
         project_config.repo_branch = "feature/232__test"
         project_config.project__git__prefix_feature = "feature/"
 
-        resolver = GitHubReleaseBranchExactMatchCommitStatusResolver()
+        resolver = GitHubExactMatch2GPResolver()
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/TwoGPRepo"
         )
@@ -524,7 +527,7 @@ class TestGitHubReleaseBranchExactMatchCommitStatusResolver:
         project_config.repo_branch = "feature/290__test"
         project_config.project__git__prefix_feature = "feature/"
 
-        resolver = GitHubReleaseBranchExactMatchCommitStatusResolver()
+        resolver = GitHubExactMatch2GPResolver()
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/TwoGPRepo"
         )
@@ -535,7 +538,38 @@ class TestGitHubReleaseBranchExactMatchCommitStatusResolver:
         project_config.repo_branch = "feature/232"
         project_config.project__git__prefix_feature = "feature/"
 
-        resolver = GitHubReleaseBranchExactMatchCommitStatusResolver()
+        resolver = GitHubExactMatch2GPResolver()
+        dep = GitHubDynamicDependency(
+            github="https://github.com/SFDO-Tooling/TwoGPMissingRepo"
+        )
+
+        assert resolver.can_resolve(dep, project_config)
+        assert resolver.resolve(dep, project_config) == (None, None)
+
+
+class TestGitHubDefaultBranch2GPResolver:
+    def test_default_branch_resolver(self, project_config):
+        project_config.repo_branch = "feature/299__test"
+        project_config.project__git__prefix_feature = "feature/"
+
+        resolver = GitHubDefaultBranch2GPResolver()
+        dep = GitHubDynamicDependency(
+            github="https://github.com/SFDO-Tooling/TwoGPRepo"
+        )
+
+        assert resolver.can_resolve(dep, project_config)
+        assert resolver.resolve(dep, project_config) == (
+            "main_sha",
+            PackageVersionIdDependency(
+                version_id="04t000000000005", package_name="CumulusCI-2GP-Test"
+            ),
+        )
+
+    def test_commit_status_not_found(self, project_config):
+        project_config.repo_branch = "feature/299"
+        project_config.project__git__prefix_feature = "feature/"
+
+        resolver = GitHubDefaultBranch2GPResolver()
         dep = GitHubDynamicDependency(
             github="https://github.com/SFDO-Tooling/TwoGPMissingRepo"
         )