Repository: dbt-labs/dbt-core

Issue:
[CT-2494] Detect and restrict project-level cycles in dependencies.yml
**Example:** 

***Project A***

(no dependencies.yml)

-- project_a_publication.json
```json
{
	"project_name": "A"
	"dependencies": []
}
```

***Project B***
Has access to project A’s publication artifact

-- dependencies.yml
```yml
projects:
- project: A
```

-- project_b_publication.json
```json
{
	"project_name": "B"
	"dependencies": ["A"]
}
```

***Project C***

Has access to project B’s publication artifact, but not project A’s
-- dependencies.yml
```yml
projects:
- project: B
```

-- project_c_publication.json
```json
{
	"project_name": "C"
	"dependencies": ["B", "A"]
}
```

If A tries to add project C to its dependencies, it will pull down C’s publication artifact which includes itself in the `dependencies` → raise project cycle error. 

If A tries to add project B to its dependencies, same thing.

If B tries to add project C to its dependencies, same thing.

Implementation Notes:
This could be done very early during parsing - as early as when publication artifacts are loaded, or could be [deferred until compilation](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/compilation.py#L418-L421) (assuming publication_dependencies are in the manifest) to keep a consistent location for cycle detection / error raising.


Discussion:

Resolving patch:
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -871,6 +871,19 @@ def get_message(self) -> str:
         return msg
 
 
+class ProjectDependencyCycleError(ParsingError):
+    def __init__(self, pub_project_name, project_name):
+        self.pub_project_name = pub_project_name
+        self.project_name = project_name
+        super().__init__(msg=self.get_message())
+
+    def get_message(self) -> str:
+        return (
+            f"A project dependency cycle has been detected. The current project {self.project_name} "
+            f"depends on {self.pub_project_name} which also depends on the current project."
+        )
+
+
 class MacroArgTypeError(CompilationError):
     def __init__(self, method_name: str, arg_name: str, got_value: Any, expected_type):
         self.method_name = method_name
diff --git a/core/dbt/parser/manifest.py b/core/dbt/parser/manifest.py
--- a/core/dbt/parser/manifest.py
+++ b/core/dbt/parser/manifest.py
@@ -103,7 +103,12 @@
     PublicModel,
     ProjectDependencies,
 )
-from dbt.exceptions import TargetNotFoundError, AmbiguousAliasError, PublicationConfigNotFound
+from dbt.exceptions import (
+    TargetNotFoundError,
+    AmbiguousAliasError,
+    PublicationConfigNotFound,
+    ProjectDependencyCycleError,
+)
 from dbt.parser.base import Parser
 from dbt.parser.analysis import AnalysisParser
 from dbt.parser.generic_test import GenericTestParser
@@ -1712,6 +1717,10 @@ def write_publication_artifact(root_project: RuntimeConfig, manifest: Manifest):
     # Get dependencies from publication dependencies
     for pub_project in manifest.publications.values():
         for project_name in pub_project.dependencies:
+            if project_name == root_project.project_name:
+                raise ProjectDependencyCycleError(
+                    pub_project_name=pub_project.project_name, project_name=project_name
+                )
             if project_name not in dependencies:
                 dependencies.append(project_name)
 


Test patch:
diff --git a/tests/functional/multi_project/test_publication.py b/tests/functional/multi_project/test_publication.py
--- a/tests/functional/multi_project/test_publication.py
+++ b/tests/functional/multi_project/test_publication.py
@@ -4,7 +4,11 @@
 
 from dbt.tests.util import run_dbt, get_artifact, write_file, copy_file
 from dbt.contracts.publication import PublicationArtifact, PublicModel
-from dbt.exceptions import PublicationConfigNotFound, TargetNotFoundError
+from dbt.exceptions import (
+    PublicationConfigNotFound,
+    TargetNotFoundError,
+    ProjectDependencyCycleError,
+)
 
 
 model_one_sql = """
@@ -257,3 +261,21 @@ def test_multi_projects(self, project, project_alt):
         write_file(dependencies_alt_yml, project.project_root, "dependencies.yml")
         results = run_dbt(["run", "--project-dir", str(project.project_root)])
         assert len(results) == 4
+
+
+class TestProjectCycles:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "model_one.sql": model_one_sql,
+        }
+
+    def test_project_cycles(self, project):
+        write_file(dependencies_yml, "dependencies.yml")
+        # Create a project dependency that's the same as the current project
+        m_pub_json = marketing_pub_json.replace('"dependencies": []', '"dependencies": ["test"]')
+        (pathlib.Path(project.project_root) / "publications").mkdir(parents=True, exist_ok=True)
+        write_file(m_pub_json, project.project_root, "publications", "marketing_publication.json")
+
+        with pytest.raises(ProjectDependencyCycleError):
+            run_dbt(["parse"])