diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md
index f4c6f17a4d9..bcd13ca3da8 100644
--- a/docs/generated/postgres-schema/README.md
+++ b/docs/generated/postgres-schema/README.md
@@ -104,6 +104,10 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.project_pool_settings](public.project_pool_settings.md) | 4 | | BASE TABLE |
| [public.project_relation](public.project_relation.md) | 5 | | BASE TABLE |
| [public.project_secrets_provider_access](public.project_secrets_provider_access.md) | 5 | | BASE TABLE |
+| [public.promotion_config](public.promotion_config.md) | 7 | | BASE TABLE |
+| [public.promotion_connection](public.promotion_connection.md) | 7 | | BASE TABLE |
+| [public.promotion_connection_project](public.promotion_connection_project.md) | 4 | | BASE TABLE |
+| [public.promotion_provider](public.promotion_provider.md) | 8 | | BASE TABLE |
| [public.role](public.role.md) | 7 | | BASE TABLE |
| [public.role_mapping_rule](public.role_mapping_rule.md) | 7 | | BASE TABLE |
| [public.role_mapping_rule_project](public.role_mapping_rule_project.md) | 2 | | BASE TABLE |
@@ -307,6 +311,10 @@ erDiagram
"public.project_relation" }o--|| "public.role" : "FOREIGN KEY (role) REFERENCES role(slug)"
"public.project_secrets_provider_access" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.project_secrets_provider_access" }o--|| "public.secrets_provider_connection" : "FOREIGN KEY (#quot;secretsProviderConnectionId#quot;) REFERENCES secrets_provider_connection(id) ON DELETE CASCADE"
+"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
+"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT"
+"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
+"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
"public.role_mapping_rule" }o--|| "public.role" : "FOREIGN KEY (role) REFERENCES role(slug) ON UPDATE CASCADE ON DELETE CASCADE"
"public.role_mapping_rule_project" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.role_mapping_rule_project" }o--|| "public.role_mapping_rule" : "FOREIGN KEY (#quot;roleMappingRuleId#quot;) REFERENCES role_mapping_rule(id) ON DELETE CASCADE"
@@ -1324,6 +1332,40 @@ erDiagram
integer secretsProviderConnectionId FK
timestamp_3__with_time_zone updatedAt
}
+"public.promotion_config" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_16_ direction
+ varchar_36_ id
+ varchar_128_ name
+ json settings
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection" {
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ json target
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection_project" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ projectId FK
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_provider" {
+ text auth
+ varchar_32_ authType
+ json config
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_32_ type
+ timestamp_3__with_time_zone updatedAt
+}
"public.role" {
timestamp_3__with_time_zone createdAt
text description
diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md
index 04551b05278..40926a8d86d 100644
--- a/docs/generated/postgres-schema/public.project.md
+++ b/docs/generated/postgres-schema/public.project.md
@@ -9,7 +9,7 @@
| customTelemetryTags | json | '[]'::json | false | | | |
| description | varchar(512) | | true | | | |
| icon | json | | true | | | |
-| id | varchar(36) | | false | [public.activity_event](public.activity_event.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_pool_settings](public.project_pool_settings.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.type_availability_policy_scope](public.type_availability_policy_scope.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | |
+| id | varchar(36) | | false | [public.activity_event](public.activity_event.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_pool_settings](public.project_pool_settings.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.promotion_connection_project](public.promotion_connection_project.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.type_availability_policy_scope](public.type_availability_policy_scope.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | |
| name | varchar(255) | | false | | | |
| type | varchar(36) | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
@@ -51,6 +51,7 @@ erDiagram
"public.project_pool_settings" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.project_relation" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.project_secrets_provider_access" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
+"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.role_mapping_rule_project" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.shared_credentials" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
"public.shared_workflow" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@@ -205,6 +206,12 @@ erDiagram
integer secretsProviderConnectionId FK
timestamp_3__with_time_zone updatedAt
}
+"public.promotion_connection_project" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ projectId FK
+ timestamp_3__with_time_zone updatedAt
+}
"public.role_mapping_rule_project" {
varchar_36_ projectId FK
varchar_16_ roleMappingRuleId FK
diff --git a/docs/generated/postgres-schema/public.promotion_config.md b/docs/generated/postgres-schema/public.promotion_config.md
new file mode 100644
index 00000000000..ef0bf07cc50
--- /dev/null
+++ b/docs/generated/postgres-schema/public.promotion_config.md
@@ -0,0 +1,66 @@
+# public.promotion_config
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| connectionId | varchar(36) | | false | | [public.promotion_connection](public.promotion_connection.md) | |
+| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+| direction | varchar(16) | | false | | | PromotionDirection enum: "apply", "promote". Cannot change after creation. |
+| id | varchar(36) | | false | | | Also the name of the local checkout directory. |
+| name | varchar(128) | | false | | | |
+| settings | json | | false | | | Branch settings for this direction, with their own schemaVersion. Validated in code. |
+| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| CHK_promotion_config_direction | CHECK | CHECK (((direction)::text = ANY ((ARRAY['apply'::character varying, 'promote'::character varying])::text[]))) |
+| FK_promotion_config_connectionId | FOREIGN KEY | FOREIGN KEY ("connectionId") REFERENCES promotion_connection(id) ON DELETE CASCADE |
+| PK_b4d534111f62d31699e6ceb544f | PRIMARY KEY | PRIMARY KEY (id) |
+| promotion_config_connectionId_not_null | n | NOT NULL "connectionId" |
+| promotion_config_createdAt_not_null | n | NOT NULL "createdAt" |
+| promotion_config_direction_not_null | n | NOT NULL direction |
+| promotion_config_id_not_null | n | NOT NULL id |
+| promotion_config_name_not_null | n | NOT NULL name |
+| promotion_config_settings_not_null | n | NOT NULL settings |
+| promotion_config_updatedAt_not_null | n | NOT NULL "updatedAt" |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_promotion_config_connectionId_direction | CREATE UNIQUE INDEX "IDX_promotion_config_connectionId_direction" ON public.promotion_config USING btree ("connectionId", direction) |
+| PK_b4d534111f62d31699e6ceb544f | CREATE UNIQUE INDEX "PK_b4d534111f62d31699e6ceb544f" ON public.promotion_config USING btree (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
+
+"public.promotion_config" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_16_ direction
+ varchar_36_ id
+ varchar_128_ name
+ json settings
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection" {
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ json target
+ timestamp_3__with_time_zone updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/postgres-schema/public.promotion_connection.md b/docs/generated/postgres-schema/public.promotion_connection.md
new file mode 100644
index 00000000000..07f341ecc18
--- /dev/null
+++ b/docs/generated/postgres-schema/public.promotion_connection.md
@@ -0,0 +1,85 @@
+# public.promotion_connection
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+| id | varchar(36) | | false | [public.promotion_config](public.promotion_config.md) [public.promotion_connection_project](public.promotion_connection_project.md) | | |
+| name | varchar(128) | | false | | | |
+| providerId | varchar(36) | | false | | [public.promotion_provider](public.promotion_provider.md) | The provider whose credentials this connection uses. |
+| scope | varchar(16) | | false | | | PromotionConnectionScope enum: "instance", "projects". Cannot change after creation. |
+| target | json | | false | | | Where to push and pull, with its own schemaVersion. Plain Git stores the full remote URL. |
+| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| CHK_promotion_connection_scope | CHECK | CHECK (((scope)::text = ANY ((ARRAY['instance'::character varying, 'projects'::character varying])::text[]))) |
+| FK_promotion_connection_providerId | FOREIGN KEY | FOREIGN KEY ("providerId") REFERENCES promotion_provider(id) ON DELETE RESTRICT |
+| PK_65cec79aba16950b45592335204 | PRIMARY KEY | PRIMARY KEY (id) |
+| promotion_connection_createdAt_not_null | n | NOT NULL "createdAt" |
+| promotion_connection_id_not_null | n | NOT NULL id |
+| promotion_connection_name_not_null | n | NOT NULL name |
+| promotion_connection_providerId_not_null | n | NOT NULL "providerId" |
+| promotion_connection_scope_not_null | n | NOT NULL scope |
+| promotion_connection_target_not_null | n | NOT NULL target |
+| promotion_connection_updatedAt_not_null | n | NOT NULL "updatedAt" |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_9917feaa6ed5705046432b117f | CREATE INDEX "IDX_9917feaa6ed5705046432b117f" ON public.promotion_connection USING btree ("providerId") |
+| IDX_promotion_connection_scope | CREATE UNIQUE INDEX "IDX_promotion_connection_scope" ON public.promotion_connection USING btree (scope) WHERE ((scope)::text = 'instance'::text) |
+| PK_65cec79aba16950b45592335204 | CREATE UNIQUE INDEX "PK_65cec79aba16950b45592335204" ON public.promotion_connection USING btree (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
+"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
+"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT"
+
+"public.promotion_connection" {
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ json target
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_config" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_16_ direction
+ varchar_36_ id
+ varchar_128_ name
+ json settings
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection_project" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ projectId FK
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_provider" {
+ text auth
+ varchar_32_ authType
+ json config
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_32_ type
+ timestamp_3__with_time_zone updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/postgres-schema/public.promotion_connection_project.md b/docs/generated/postgres-schema/public.promotion_connection_project.md
new file mode 100644
index 00000000000..1cb03b74698
--- /dev/null
+++ b/docs/generated/postgres-schema/public.promotion_connection_project.md
@@ -0,0 +1,69 @@
+# public.promotion_connection_project
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| connectionId | varchar(36) | | false | | [public.promotion_connection](public.promotion_connection.md) | |
+| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+| projectId | varchar(36) | | false | | [public.project](public.project.md) | |
+| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| FK_promotion_connection_project_connectionId | FOREIGN KEY | FOREIGN KEY ("connectionId") REFERENCES promotion_connection(id) ON DELETE CASCADE |
+| FK_promotion_connection_project_projectId | FOREIGN KEY | FOREIGN KEY ("projectId") REFERENCES project(id) ON DELETE CASCADE |
+| PK_0c63d9094bf8ca78349ba860147 | PRIMARY KEY | PRIMARY KEY ("projectId") |
+| promotion_connection_project_connectionId_not_null | n | NOT NULL "connectionId" |
+| promotion_connection_project_createdAt_not_null | n | NOT NULL "createdAt" |
+| promotion_connection_project_projectId_not_null | n | NOT NULL "projectId" |
+| promotion_connection_project_updatedAt_not_null | n | NOT NULL "updatedAt" |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_2c579b1068b66359de1b4d2de5 | CREATE INDEX "IDX_2c579b1068b66359de1b4d2de5" ON public.promotion_connection_project USING btree ("connectionId") |
+| PK_0c63d9094bf8ca78349ba860147 | CREATE UNIQUE INDEX "PK_0c63d9094bf8ca78349ba860147" ON public.promotion_connection_project USING btree ("projectId") |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
+"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
+
+"public.promotion_connection_project" {
+ varchar_36_ connectionId FK
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ projectId FK
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection" {
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ json target
+ timestamp_3__with_time_zone updatedAt
+}
+"public.project" {
+ timestamp_3__with_time_zone createdAt
+ uuid creatorId FK
+ json customTelemetryTags
+ varchar_512_ description
+ json icon
+ varchar_36_ id
+ varchar_255_ name
+ varchar_36_ type
+ timestamp_3__with_time_zone updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/postgres-schema/public.promotion_provider.md b/docs/generated/postgres-schema/public.promotion_provider.md
new file mode 100644
index 00000000000..0769e5ddae8
--- /dev/null
+++ b/docs/generated/postgres-schema/public.promotion_provider.md
@@ -0,0 +1,68 @@
+# public.promotion_provider
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| auth | text | | false | | | Encrypted credentials. Never sent to a client. |
+| authType | varchar(32) | | false | | | PromotionProviderAuthType enum: "ssh-key", "token". "token" is an HTTP(S) username and password. |
+| config | json | | false | | | Non-secret provider settings, with their own schemaVersion. Validated in code. |
+| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+| id | varchar(36) | | false | [public.promotion_connection](public.promotion_connection.md) | | |
+| name | varchar(128) | | false | | | |
+| type | varchar(32) | | false | | | PromotionProviderType enum: "git" |
+| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| CHK_promotion_provider_authType | CHECK | CHECK ((("authType")::text = ANY ((ARRAY['ssh-key'::character varying, 'token'::character varying])::text[]))) |
+| CHK_promotion_provider_type | CHECK | CHECK (((type)::text = 'git'::text)) |
+| PK_6e968555ff44699f59ad10be765 | PRIMARY KEY | PRIMARY KEY (id) |
+| promotion_provider_authType_not_null | n | NOT NULL "authType" |
+| promotion_provider_auth_not_null | n | NOT NULL auth |
+| promotion_provider_config_not_null | n | NOT NULL config |
+| promotion_provider_createdAt_not_null | n | NOT NULL "createdAt" |
+| promotion_provider_id_not_null | n | NOT NULL id |
+| promotion_provider_name_not_null | n | NOT NULL name |
+| promotion_provider_type_not_null | n | NOT NULL type |
+| promotion_provider_updatedAt_not_null | n | NOT NULL "updatedAt" |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| PK_6e968555ff44699f59ad10be765 | CREATE UNIQUE INDEX "PK_6e968555ff44699f59ad10be765" ON public.promotion_provider USING btree (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT"
+
+"public.promotion_provider" {
+ text auth
+ varchar_32_ authType
+ json config
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_32_ type
+ timestamp_3__with_time_zone updatedAt
+}
+"public.promotion_connection" {
+ timestamp_3__with_time_zone createdAt
+ varchar_36_ id
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ json target
+ timestamp_3__with_time_zone updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md
index 93a159708f9..ceb2751ae27 100644
--- a/docs/generated/sqlite-schema/README.md
+++ b/docs/generated/sqlite-schema/README.md
@@ -104,6 +104,10 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [project_pool_settings](project_pool_settings.md) | 4 | | table |
| [project_relation](project_relation.md) | 5 | | table |
| [project_secrets_provider_access](project_secrets_provider_access.md) | 5 | | table |
+| [promotion_config](promotion_config.md) | 7 | | table |
+| [promotion_connection](promotion_connection.md) | 7 | | table |
+| [promotion_connection_project](promotion_connection_project.md) | 4 | | table |
+| [promotion_provider](promotion_provider.md) | 8 | | table |
| [role](role.md) | 7 | | table |
| [role_mapping_rule](role_mapping_rule.md) | 7 | | table |
| [role_mapping_rule_project](role_mapping_rule_project.md) | 2 | | table |
@@ -290,6 +294,10 @@ erDiagram
"project_relation" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"project_secrets_provider_access" |o--|| "secrets_provider_connection" : "FOREIGN KEY (secretsProviderConnectionId) REFERENCES secrets_provider_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"project_secrets_provider_access" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
+"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"role_mapping_rule" }o--|| "role" : "FOREIGN KEY (role) REFERENCES role (slug) ON UPDATE CASCADE ON DELETE CASCADE MATCH NONE"
"role_mapping_rule_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"role_mapping_rule_project" |o--|| "role_mapping_rule" : "FOREIGN KEY (roleMappingRuleId) REFERENCES role_mapping_rule (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -1311,6 +1319,40 @@ erDiagram
INTEGER secretsProviderConnectionId PK
datetime_3_ updatedAt
}
+"promotion_config" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_16_ direction
+ varchar_36_ id PK
+ varchar_128_ name
+ TEXT settings
+ datetime_3_ updatedAt
+}
+"promotion_connection" {
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ TEXT target
+ datetime_3_ updatedAt
+}
+"promotion_connection_project" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_36_ projectId PK
+ datetime_3_ updatedAt
+}
+"promotion_provider" {
+ TEXT auth
+ varchar_32_ authType
+ TEXT config
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_32_ type
+ datetime_3_ updatedAt
+}
"role" {
datetime_3_ createdAt
TEXT description
diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md
index f2adf8c9679..c546586b120 100644
--- a/docs/generated/sqlite-schema/project.md
+++ b/docs/generated/sqlite-schema/project.md
@@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25
| customTelemetryTags | TEXT | '[]' | false | | | |
| description | varchar(512) | | true | | | |
| icon | TEXT | | true | | | |
-| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
+| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [promotion_connection_project](promotion_connection_project.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | |
| name | varchar(255) | | false | | | |
| type | varchar(36) | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
@@ -57,6 +57,7 @@ erDiagram
"project_pool_settings" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"project_relation" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"project_secrets_provider_access" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"role_mapping_rule_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"shared_credentials" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"shared_workflow" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -211,6 +212,12 @@ erDiagram
INTEGER secretsProviderConnectionId PK
datetime_3_ updatedAt
}
+"promotion_connection_project" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_36_ projectId PK
+ datetime_3_ updatedAt
+}
"role_mapping_rule_project" {
varchar_36_ projectId PK
varchar_16_ roleMappingRuleId PK
diff --git a/docs/generated/sqlite-schema/promotion_config.md b/docs/generated/sqlite-schema/promotion_config.md
new file mode 100644
index 00000000000..1362b193428
--- /dev/null
+++ b/docs/generated/sqlite-schema/promotion_config.md
@@ -0,0 +1,71 @@
+# promotion_config
+
+## Description
+
+
+Table Definition
+
+```sql
+CREATE TABLE "promotion_config" ("id" varchar(36) PRIMARY KEY NOT NULL, "connectionId" varchar(36) NOT NULL, "name" varchar(128) NOT NULL, "direction" varchar(16) NOT NULL, "settings" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_config_direction" CHECK ("direction" IN ('apply', 'promote')), CONSTRAINT "FK_promotion_config_connectionId" FOREIGN KEY ("connectionId") REFERENCES "promotion_connection" ("id") ON DELETE CASCADE)
+```
+
+
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| connectionId | varchar(36) | | false | | [promotion_connection](promotion_connection.md) | |
+| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+| direction | varchar(16) | | false | | | |
+| id | varchar(36) | | false | | | |
+| name | varchar(128) | | false | | | |
+| settings | TEXT | | false | | | |
+| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| - | CHECK | CHECK ("direction" IN ('apply', 'promote')) |
+| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| id | PRIMARY KEY | PRIMARY KEY (id) |
+| sqlite_autoindex_promotion_config_1 | PRIMARY KEY | PRIMARY KEY (id) |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_promotion_config_connectionId_direction | CREATE UNIQUE INDEX "IDX_promotion_config_connectionId_direction" ON "promotion_config" ("connectionId", "direction") |
+| sqlite_autoindex_promotion_config_1 | PRIMARY KEY (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+
+"promotion_config" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_16_ direction
+ varchar_36_ id PK
+ varchar_128_ name
+ TEXT settings
+ datetime_3_ updatedAt
+}
+"promotion_connection" {
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ TEXT target
+ datetime_3_ updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/sqlite-schema/promotion_connection.md b/docs/generated/sqlite-schema/promotion_connection.md
new file mode 100644
index 00000000000..5674fd75eb5
--- /dev/null
+++ b/docs/generated/sqlite-schema/promotion_connection.md
@@ -0,0 +1,90 @@
+# promotion_connection
+
+## Description
+
+
+Table Definition
+
+```sql
+CREATE TABLE "promotion_connection" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "providerId" varchar(36) NOT NULL, "scope" varchar(16) NOT NULL, "target" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_connection_scope" CHECK ("scope" IN ('instance', 'projects')), CONSTRAINT "FK_promotion_connection_providerId" FOREIGN KEY ("providerId") REFERENCES "promotion_provider" ("id") ON DELETE RESTRICT)
+```
+
+
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+| id | varchar(36) | | false | [promotion_config](promotion_config.md) [promotion_connection_project](promotion_connection_project.md) | | |
+| name | varchar(128) | | false | | | |
+| providerId | varchar(36) | | false | | [promotion_provider](promotion_provider.md) | |
+| scope | varchar(16) | | false | | | |
+| target | TEXT | | false | | | |
+| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| - | CHECK | CHECK ("scope" IN ('instance', 'projects')) |
+| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE |
+| id | PRIMARY KEY | PRIMARY KEY (id) |
+| sqlite_autoindex_promotion_connection_1 | PRIMARY KEY | PRIMARY KEY (id) |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_9917feaa6ed5705046432b117f | CREATE INDEX "IDX_9917feaa6ed5705046432b117f" ON "promotion_connection" ("providerId") |
+| IDX_promotion_connection_scope | CREATE UNIQUE INDEX "IDX_promotion_connection_scope" ON "promotion_connection" ("scope") WHERE "scope" = 'instance' |
+| sqlite_autoindex_promotion_connection_1 | PRIMARY KEY (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
+
+"promotion_connection" {
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ TEXT target
+ datetime_3_ updatedAt
+}
+"promotion_config" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_16_ direction
+ varchar_36_ id PK
+ varchar_128_ name
+ TEXT settings
+ datetime_3_ updatedAt
+}
+"promotion_connection_project" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_36_ projectId PK
+ datetime_3_ updatedAt
+}
+"promotion_provider" {
+ TEXT auth
+ varchar_32_ authType
+ TEXT config
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_32_ type
+ datetime_3_ updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/sqlite-schema/promotion_connection_project.md b/docs/generated/sqlite-schema/promotion_connection_project.md
new file mode 100644
index 00000000000..4348dc0916d
--- /dev/null
+++ b/docs/generated/sqlite-schema/promotion_connection_project.md
@@ -0,0 +1,77 @@
+# promotion_connection_project
+
+## Description
+
+
+Table Definition
+
+```sql
+CREATE TABLE "promotion_connection_project" ("projectId" varchar(36) PRIMARY KEY NOT NULL, "connectionId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_promotion_connection_project_projectId" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE, CONSTRAINT "FK_promotion_connection_project_connectionId" FOREIGN KEY ("connectionId") REFERENCES "promotion_connection" ("id") ON DELETE CASCADE)
+```
+
+
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| connectionId | varchar(36) | | false | | [promotion_connection](promotion_connection.md) | |
+| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+| projectId | varchar(36) | | false | | [project](project.md) | |
+| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
+| projectId | PRIMARY KEY | PRIMARY KEY (projectId) |
+| sqlite_autoindex_promotion_connection_project_1 | PRIMARY KEY | PRIMARY KEY (projectId) |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| IDX_2c579b1068b66359de1b4d2de5 | CREATE INDEX "IDX_2c579b1068b66359de1b4d2de5" ON "promotion_connection_project" ("connectionId") |
+| sqlite_autoindex_promotion_connection_project_1 | PRIMARY KEY (projectId) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
+
+"promotion_connection_project" {
+ varchar_36_ connectionId FK
+ datetime_3_ createdAt
+ varchar_36_ projectId PK
+ datetime_3_ updatedAt
+}
+"promotion_connection" {
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ TEXT target
+ datetime_3_ updatedAt
+}
+"project" {
+ datetime_3_ createdAt
+ varchar creatorId FK
+ TEXT customTelemetryTags
+ varchar_512_ description
+ TEXT icon
+ varchar_36_ id PK
+ varchar_255_ name
+ varchar_36_ type
+ datetime_3_ updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/docs/generated/sqlite-schema/promotion_provider.md b/docs/generated/sqlite-schema/promotion_provider.md
new file mode 100644
index 00000000000..03a04615e63
--- /dev/null
+++ b/docs/generated/sqlite-schema/promotion_provider.md
@@ -0,0 +1,72 @@
+# promotion_provider
+
+## Description
+
+
+Table Definition
+
+```sql
+CREATE TABLE "promotion_provider" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "type" varchar(32) NOT NULL, "authType" varchar(32) NOT NULL, "config" text NOT NULL, "auth" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_provider_type" CHECK ("type" IN ('git')), CONSTRAINT "CHK_promotion_provider_authType" CHECK ("authType" IN ('ssh-key', 'token')))
+```
+
+
+
+## Columns
+
+| Name | Type | Default | Nullable | Children | Parents | Comment |
+| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
+| auth | TEXT | | false | | | |
+| authType | varchar(32) | | false | | | |
+| config | TEXT | | false | | | |
+| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+| id | varchar(36) | | false | [promotion_connection](promotion_connection.md) | | |
+| name | varchar(128) | | false | | | |
+| type | varchar(32) | | false | | | |
+| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
+
+## Constraints
+
+| Name | Type | Definition |
+| ---- | ---- | ---------- |
+| - | CHECK | CHECK ("type" IN ('git')) |
+| - | CHECK | CHECK ("authType" IN ('ssh-key', 'token')) |
+| id | PRIMARY KEY | PRIMARY KEY (id) |
+| sqlite_autoindex_promotion_provider_1 | PRIMARY KEY | PRIMARY KEY (id) |
+
+## Indexes
+
+| Name | Definition |
+| ---- | ---------- |
+| sqlite_autoindex_promotion_provider_1 | PRIMARY KEY (id) |
+
+## Relations
+
+```mermaid
+erDiagram
+
+"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
+
+"promotion_provider" {
+ TEXT auth
+ varchar_32_ authType
+ TEXT config
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_32_ type
+ datetime_3_ updatedAt
+}
+"promotion_connection" {
+ datetime_3_ createdAt
+ varchar_36_ id PK
+ varchar_128_ name
+ varchar_36_ providerId FK
+ varchar_16_ scope
+ TEXT target
+ datetime_3_ updatedAt
+}
+```
+
+---
+
+> Generated by [tbls](https://github.com/k1LoW/tbls)
diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts
index 4f7eb83bf69..9bc9bf1e0c9 100644
--- a/packages/@n8n/api-types/src/dto/index.ts
+++ b/packages/@n8n/api-types/src/dto/index.ts
@@ -461,3 +461,75 @@ export {
type PromotableResourceType,
type PromoteRequest,
} from './promotions/promotable-resource.dto';
+
+export { promotionDisplayNameSchema } from './promotions/promotion-common.dto';
+export {
+ CreatePromotionProviderDto,
+ ListPromotionProvidersQueryDto,
+ PromotionProviderCreatedPublicDto,
+ PromotionProviderListPublicDto,
+ PromotionProviderPublicDto,
+ UpdatePromotionProviderDto,
+ promotionGitConfigSchemas,
+ promotionGitSshKeyAuthInputSchema,
+ promotionGitSshKeyAuthUpdateSchema,
+ promotionGitSshKeyConfigSchema,
+ promotionGitTokenAuthInputSchema,
+ promotionGitTokenConfigSchema,
+ promotionProviderAuthInputSchema,
+ promotionProviderAuthTypeSchema,
+ promotionProviderAuthUpdateSchema,
+ promotionProviderConfigSchema,
+ promotionProviderPublicSchema,
+ promotionProviderSummarySchema,
+ promotionProviderTypeSchema,
+ promotionSshKeyTypeSchema,
+ type PromotionProviderAuthInput,
+ type PromotionProviderAuthType,
+ type PromotionProviderAuthUpdate,
+ type PromotionProviderConfig,
+ type PromotionProviderType,
+ type PromotionSshKeyType,
+} from './promotions/promotion-provider.dto';
+export {
+ PromotionApplyConfigPublicDto,
+ PromotionPromoteConfigPublicDto,
+ UpsertPromotionApplyConfigDto,
+ UpsertPromotionPromoteConfigDto,
+ promotionApplyConfigPublicSchema,
+ promotionConfigSettingsSchema,
+ promotionConnectionConfigsPublicSchema,
+ promotionDirectionSchema,
+ promotionGitApplySettingsSchema,
+ promotionGitPromoteSettingsSchema,
+ promotionGitSettingsSchemas,
+ promotionPromoteConfigPublicSchema,
+ type PromotionConfigSettings,
+ type PromotionConnectionConfigsPublic,
+ type PromotionDirection,
+} from './promotions/promotion-config.dto';
+export {
+ CreatePromotionConnectionDto,
+ ListPromotionConnectionsQueryDto,
+ PromotionConnectionListPublicDto,
+ PromotionConnectionProjectListPublicDto,
+ PromotionConnectionProjectPublicDto,
+ PromotionConnectionPublicDto,
+ UpdatePromotionConnectionDto,
+ promotionConnectionProjectPublicSchema,
+ promotionConnectionPublicSchema,
+ promotionConnectionScopeSchema,
+ promotionConnectionTargetSchema,
+ type PromotionConnectionScope,
+ type PromotionConnectionTarget,
+} from './promotions/promotion-connection.dto';
+export {
+ ApplyPackageResultDto,
+ PromotePackageDto,
+ PromotePackageResultDto,
+ applyPackageCountsSchema,
+ applyPackageResultSchema,
+ promotePackageCountsSchema,
+ promotePackageResultSchema,
+ promotionGitResultSchema,
+} from './promotions/promotion-operations.dto';
diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts
new file mode 100644
index 00000000000..f4216fe652b
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts
@@ -0,0 +1,107 @@
+import {
+ PromotionPromoteConfigPublicDto,
+ UpsertPromotionApplyConfigDto,
+ UpsertPromotionPromoteConfigDto,
+} from '../promotion-config.dto';
+
+const applySettings = { schemaVersion: 1 as const, branchName: 'dev' };
+const promoteSettings = {
+ schemaVersion: 1 as const,
+ baseBranchName: 'staging',
+ createBranchOnPromotion: false,
+};
+
+describe('UpsertPromotionApplyConfigDto', () => {
+ it('accepts settings with an optional name', () => {
+ expect(UpsertPromotionApplyConfigDto.safeParse({ settings: applySettings }).success).toBe(true);
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ name: 'Apply', settings: applySettings }).success,
+ ).toBe(true);
+ });
+
+ // The direction is in the path, so a body that names one is malformed.
+ it('rejects a direction and a connection in the body', () => {
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ direction: 'apply', settings: applySettings })
+ .success,
+ ).toBe(false);
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ connectionId: 'conn-1', settings: applySettings })
+ .success,
+ ).toBe(false);
+ });
+
+ it('rejects promote settings and the branching flag', () => {
+ expect(UpsertPromotionApplyConfigDto.safeParse({ settings: promoteSettings }).success).toBe(
+ false,
+ );
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({
+ settings: { ...applySettings, createBranchOnPromotion: true },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('requires a branch name of at most 255 characters', () => {
+ const settings = (branchName: string) => ({ schemaVersion: 1, branchName });
+
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ settings: { schemaVersion: 1 } }).success,
+ ).toBe(false);
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ settings: settings('a'.repeat(255)) }).success,
+ ).toBe(true);
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({ settings: settings('a'.repeat(256)) }).success,
+ ).toBe(false);
+ });
+
+ it('rejects settings of an unsupported schema version', () => {
+ expect(
+ UpsertPromotionApplyConfigDto.safeParse({
+ settings: { schemaVersion: 2, branchName: 'dev' },
+ }).success,
+ ).toBe(false);
+ });
+});
+
+describe('UpsertPromotionPromoteConfigDto', () => {
+ // A write replaces the whole config, so the flag has no default to fall back on.
+ it('requires createBranchOnPromotion, so a write cannot reset it by omission', () => {
+ expect(
+ UpsertPromotionPromoteConfigDto.safeParse({
+ settings: { schemaVersion: 1, baseBranchName: 'staging' },
+ }).success,
+ ).toBe(false);
+ expect(UpsertPromotionPromoteConfigDto.safeParse({ settings: promoteSettings }).success).toBe(
+ true,
+ );
+ });
+
+ it('rejects the apply branch field', () => {
+ expect(UpsertPromotionPromoteConfigDto.safeParse({ settings: applySettings }).success).toBe(
+ false,
+ );
+ });
+});
+
+describe('PromotionPromoteConfigPublicDto', () => {
+ const base = {
+ id: 'cfg-1',
+ name: 'Promote',
+ createdAt: '2026-09-07T08:00:00.000Z',
+ updatedAt: '2026-09-07T08:00:00.000Z',
+ };
+
+ it('always reports createBranchOnPromotion', () => {
+ expect(
+ PromotionPromoteConfigPublicDto.safeParse({
+ ...base,
+ settings: { schemaVersion: 1, baseBranchName: 'staging' },
+ }).success,
+ ).toBe(false);
+ expect(
+ PromotionPromoteConfigPublicDto.safeParse({ ...base, settings: promoteSettings }).success,
+ ).toBe(true);
+ });
+});
diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts
new file mode 100644
index 00000000000..ae1a4092cc3
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts
@@ -0,0 +1,137 @@
+import {
+ CreatePromotionConnectionDto,
+ PromotionConnectionPublicDto,
+ UpdatePromotionConnectionDto,
+} from '../promotion-connection.dto';
+
+const target = { schemaVersion: 1 as const, remoteUrl: 'git@example.com:team/workflows.git' };
+const applyConfig = { settings: { schemaVersion: 1 as const, branchName: 'dev' } };
+const promoteConfig = {
+ settings: {
+ schemaVersion: 1 as const,
+ baseBranchName: 'staging',
+ createBranchOnPromotion: false,
+ },
+};
+
+const newConnection = {
+ name: 'Staging',
+ scope: 'instance' as const,
+ providerId: 'prov-1',
+ target,
+};
+
+const timestamp = '2026-09-07T08:00:00.000Z';
+const publicProvider = {
+ id: 'prov-1',
+ name: 'Staging',
+ type: 'git' as const,
+ authType: 'ssh-key' as const,
+ config: {
+ schemaVersion: 1 as const,
+ publicKey: 'ssh-ed25519 AAAAKEY',
+ keyType: 'ed25519' as const,
+ },
+ createdAt: timestamp,
+ updatedAt: timestamp,
+};
+
+describe('CreatePromotionConnectionDto', () => {
+ it('accepts a connection with no configs, so a public key can be read before cloning', () => {
+ expect(CreatePromotionConnectionDto.safeParse(newConnection).success).toBe(true);
+ });
+
+ it('accepts a config for each direction, keyed by direction', () => {
+ expect(
+ CreatePromotionConnectionDto.safeParse({
+ ...newConnection,
+ configs: { apply: applyConfig, promote: promoteConfig },
+ }).success,
+ ).toBe(true);
+ expect(
+ CreatePromotionConnectionDto.safeParse({ ...newConnection, configs: { apply: applyConfig } })
+ .success,
+ ).toBe(true);
+ });
+
+ // Keying by direction is what makes "one per direction" structural.
+ it('rejects a config array and an unknown direction key', () => {
+ expect(
+ CreatePromotionConnectionDto.safeParse({
+ ...newConnection,
+ configs: [applyConfig, promoteConfig],
+ }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionConnectionDto.safeParse({ ...newConnection, configs: { sync: applyConfig } })
+ .success,
+ ).toBe(false);
+ });
+
+ it('rejects settings under the wrong direction key', () => {
+ expect(
+ CreatePromotionConnectionDto.safeParse({
+ ...newConnection,
+ configs: { apply: promoteConfig },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('requires a provider id and rejects a target of an unsupported version', () => {
+ expect(
+ CreatePromotionConnectionDto.safeParse({ ...newConnection, providerId: '' }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionConnectionDto.safeParse({
+ ...newConnection,
+ target: { schemaVersion: 2, remoteUrl: 'git@example.com:team/workflows.git' },
+ }).success,
+ ).toBe(false);
+ });
+});
+
+describe('UpdatePromotionConnectionDto', () => {
+ it('accepts the name, the target, and the provider', () => {
+ expect(
+ UpdatePromotionConnectionDto.safeParse({ name: 'Renamed', target, providerId: 'prov-2' })
+ .success,
+ ).toBe(true);
+ });
+
+ it('rejects configs, a scope change, and an operation payload', () => {
+ expect(
+ UpdatePromotionConnectionDto.safeParse({ name: 'Renamed', configs: { apply: applyConfig } })
+ .success,
+ ).toBe(false);
+ expect(UpdatePromotionConnectionDto.safeParse({ scope: 'projects' }).success).toBe(false);
+ expect(UpdatePromotionConnectionDto.safeParse({ commitMessage: 'Update' }).success).toBe(false);
+ });
+});
+
+describe('PromotionConnectionPublicDto', () => {
+ // Detail and list share this shape, so a full provider would put the key in every row.
+ it('embeds the provider without its public key', () => {
+ const result = PromotionConnectionPublicDto.safeParse({
+ id: 'conn-1',
+ name: 'Staging',
+ scope: 'instance',
+ target,
+ provider: publicProvider,
+ configs: {
+ apply: {
+ ...applyConfig,
+ id: 'cfg-1',
+ name: 'Apply',
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ },
+ },
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ });
+
+ if (!result.success) throw result.error;
+ expect(result.data.provider).not.toHaveProperty('config');
+ expect(JSON.stringify(result.data)).not.toContain('AAAAKEY');
+ });
+});
diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts
new file mode 100644
index 00000000000..076b549f815
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts
@@ -0,0 +1,19 @@
+import { PromotePackageDto } from '../promotion-operations.dto';
+
+describe('PromotePackageDto', () => {
+ it('requires a non-blank commit message and accepts an optional force flag', () => {
+ expect(PromotePackageDto.safeParse({ commitMessage: 'Update projects' }).success).toBe(true);
+ expect(PromotePackageDto.safeParse({ commitMessage: 'Update', force: true }).success).toBe(
+ true,
+ );
+ expect(PromotePackageDto.safeParse({}).success).toBe(false);
+ expect(PromotePackageDto.safeParse({ commitMessage: ' ' }).success).toBe(false);
+ });
+
+ // The branch comes from the stored config, never from the request.
+ it('rejects unknown fields, so an operation cannot be steered from the request', () => {
+ expect(
+ PromotePackageDto.safeParse({ commitMessage: 'Update', branchName: 'main' }).success,
+ ).toBe(false);
+ });
+});
diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts
new file mode 100644
index 00000000000..ad855237d57
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts
@@ -0,0 +1,157 @@
+import {
+ CreatePromotionProviderDto,
+ PromotionProviderListPublicDto,
+ PromotionProviderPublicDto,
+ UpdatePromotionProviderDto,
+} from '../promotion-provider.dto';
+
+const provider = { name: 'Staging', type: 'git' as const };
+const sshAuth = { authType: 'ssh-key' as const };
+const tokenAuth = { authType: 'token' as const, username: 'git-user', password: 'secret' };
+
+const timestamp = '2026-09-07T08:00:00.000Z';
+const publicProvider = {
+ ...provider,
+ id: 'prov-1',
+ authType: 'ssh-key' as const,
+ config: {
+ schemaVersion: 1 as const,
+ publicKey: 'ssh-ed25519 AAAAKEY',
+ keyType: 'ed25519' as const,
+ },
+ createdAt: timestamp,
+ updatedAt: timestamp,
+};
+
+describe('CreatePromotionProviderDto', () => {
+ it('defaults an SSH key type to ed25519', () => {
+ const result = CreatePromotionProviderDto.safeParse({ ...provider, auth: sshAuth });
+
+ if (!result.success) throw result.error;
+ expect(result.data.auth).toEqual({ authType: 'ssh-key', keyType: 'ed25519' });
+ });
+
+ it('trims the username but stores the password as sent', () => {
+ const result = CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: { ...tokenAuth, username: ' git-user ', password: ' secret ' },
+ });
+
+ if (!result.success) throw result.error;
+ expect(result.data.auth).toEqual({
+ authType: 'token',
+ username: 'git-user',
+ password: ' secret ',
+ });
+ });
+
+ // The auth type lives inside `auth`, so credentials cannot be paired with
+ // another auth type's fields.
+ it('rejects an auth payload that mixes the two auth types', () => {
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: { ...tokenAuth, keyType: 'rsa' },
+ }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: { authType: 'ssh-key', username: 'git-user', password: 'secret' },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects an auth payload with no auth type or no credentials', () => {
+ expect(CreatePromotionProviderDto.safeParse({ ...provider, auth: {} }).success).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({ ...provider, auth: { authType: 'token' } }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: { authType: 'token', username: 'git-user' },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects a blank name, an over-long name, and a blank password', () => {
+ expect(
+ CreatePromotionProviderDto.safeParse({ ...provider, name: ' ', auth: tokenAuth }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ name: 'a'.repeat(129),
+ auth: tokenAuth,
+ }).success,
+ ).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: { ...tokenAuth, password: '' },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects a top-level auth type and a config', () => {
+ expect(
+ CreatePromotionProviderDto.safeParse({ ...provider, authType: 'token', auth: tokenAuth })
+ .success,
+ ).toBe(false);
+ expect(
+ CreatePromotionProviderDto.safeParse({
+ ...provider,
+ auth: sshAuth,
+ config: { schemaVersion: 1, publicKey: 'ssh-ed25519 AAAAKEY', keyType: 'ed25519' },
+ }).success,
+ ).toBe(false);
+ });
+});
+
+describe('UpdatePromotionProviderDto', () => {
+ it('keeps the stored credentials when auth is omitted', () => {
+ expect(UpdatePromotionProviderDto.safeParse({ name: 'Renamed' }).success).toBe(true);
+ });
+
+ it('leaves the key type unset, so a rotation can keep the current algorithm', () => {
+ const result = UpdatePromotionProviderDto.safeParse({ auth: sshAuth });
+
+ if (!result.success) throw result.error;
+ expect(result.data.auth).toEqual({ authType: 'ssh-key' });
+ });
+
+ it('still requires both credentials for a token provider', () => {
+ expect(UpdatePromotionProviderDto.safeParse({ auth: { authType: 'token' } }).success).toBe(
+ false,
+ );
+ expect(UpdatePromotionProviderDto.safeParse({ auth: tokenAuth }).success).toBe(true);
+ });
+
+ it('rejects a change to the provider type or the config', () => {
+ expect(UpdatePromotionProviderDto.safeParse({ type: 'git' }).success).toBe(false);
+ expect(UpdatePromotionProviderDto.safeParse({ config: { schemaVersion: 1 } }).success).toBe(
+ false,
+ );
+ });
+});
+
+describe('provider responses', () => {
+ it('leaves out the encrypted credentials', () => {
+ const result = PromotionProviderPublicDto.safeParse({ ...publicProvider, auth: 'encrypted' });
+
+ if (!result.success) throw result.error;
+ expect(result.data).not.toHaveProperty('auth');
+ });
+
+ it('drops the public key from list rows', () => {
+ const result = PromotionProviderListPublicDto.safeParse({
+ data: [publicProvider],
+ nextCursor: null,
+ });
+
+ if (!result.success) throw result.error;
+ expect(result.data.data[0]).not.toHaveProperty('config');
+ expect(JSON.stringify(result.data)).not.toContain('AAAAKEY');
+ });
+});
diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts
new file mode 100644
index 00000000000..f359754dbd9
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod';
+
+/**
+ * Display name of a provider, connection, or config. All three are `varchar(128)`,
+ * so cap the length here to reject over-long text with a 400 instead of a 500.
+ * Names do not have to be unique.
+ */
+export const promotionDisplayNameSchema = z.string().trim().min(1).max(128);
diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts
new file mode 100644
index 00000000000..75b7e1b7eee
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts
@@ -0,0 +1,124 @@
+import { z } from 'zod';
+
+import { promotionDisplayNameSchema } from './promotion-common.dto';
+import { Z } from '../../zod-class';
+
+export const promotionDirectionSchema = z.enum(['apply', 'promote']);
+export type PromotionDirection = z.infer;
+
+/**
+ * Every config carries a branch for its direction. A direction that is not set up
+ * has no config at all. Git sets no limit on branch names, so cap it here to keep
+ * the payload small. The backend still checks the name with Git.
+ */
+const branchNameSchema = z.string().trim().min(1).max(255);
+
+/** Apply imports the package from `branchName`. */
+export const promotionGitApplySettingsSchema = z
+ .object({ schemaVersion: z.literal(1), branchName: branchNameSchema })
+ .strict();
+
+/**
+ * Promote starts from `baseBranchName`. With branching off it pushes there
+ * directly. With branching on it creates the promotion branch from there. The
+ * branch it really pushed to comes back in the result and is never saved here.
+ *
+ * `createBranchOnPromotion` is always required. A write replaces the whole config,
+ * so a default here would let an unrelated edit reset it. The off-by-default
+ * choice lives in the UI toggle and the CLI examples instead.
+ */
+export const promotionGitPromoteSettingsSchema = z
+ .object({
+ schemaVersion: z.literal(1),
+ baseBranchName: branchNameSchema,
+ createBranchOnPromotion: z.boolean(),
+ })
+ .strict();
+
+/**
+ * Settings schema for each direction. Use this map when the direction is known,
+ * such as when checking a stored row. A new provider type adds its own map, with
+ * its own fields and no branch name.
+ */
+export const promotionGitSettingsSchemas = {
+ apply: promotionGitApplySettingsSchema,
+ promote: promotionGitPromoteSettingsSchema,
+} as const;
+
+export const promotionConfigSettingsSchema = z.union([
+ promotionGitApplySettingsSchema,
+ promotionGitPromoteSettingsSchema,
+]);
+export type PromotionConfigSettings = z.infer;
+
+/*
+ * Request bodies for `PUT /connections/:id/configs/:direction`, which creates the
+ * config or replaces it. The direction is in the path, so the body needs no
+ * discriminator and each direction gets a plain object of its own.
+ *
+ * A write replaces the whole config. An omitted `name` resets it to the direction
+ * label, so a client changing only a branch has to resend the name it wants to
+ * keep. The connection-create payload reuses these same two schemas.
+ */
+
+export class UpsertPromotionApplyConfigDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema.optional(),
+ settings: promotionGitApplySettingsSchema,
+ },
+ { strict: true },
+) {}
+
+export class UpsertPromotionPromoteConfigDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema.optional(),
+ settings: promotionGitPromoteSettingsSchema,
+ },
+ { strict: true },
+) {}
+
+const configPublicFields = {
+ /**
+ * Also names the local checkout directory. Stable while the config exists, and
+ * replaced by a new one if the direction is deleted and set up again.
+ */
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+};
+
+/*
+ * Responses leave out the connection and the direction: both are the address the
+ * caller already used.
+ */
+
+export const promotionApplyConfigPublicSchema = z.object({
+ ...configPublicFields,
+ settings: promotionGitApplySettingsSchema,
+});
+
+export class PromotionApplyConfigPublicDto extends Z.class(
+ promotionApplyConfigPublicSchema.shape,
+) {}
+
+export const promotionPromoteConfigPublicSchema = z.object({
+ ...configPublicFields,
+ settings: promotionGitPromoteSettingsSchema,
+});
+
+export class PromotionPromoteConfigPublicDto extends Z.class(
+ promotionPromoteConfigPublicSchema.shape,
+) {}
+
+/**
+ * Configs keyed by direction, so a client reads `configs.promote?.settings`
+ * without narrowing a union. A missing key means that direction is not set up.
+ */
+export const promotionConnectionConfigsPublicSchema = z.object({
+ apply: promotionApplyConfigPublicSchema.optional(),
+ promote: promotionPromoteConfigPublicSchema.optional(),
+});
+export type PromotionConnectionConfigsPublic = z.infer<
+ typeof promotionConnectionConfigsPublicSchema
+>;
diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts
new file mode 100644
index 00000000000..a2532b92b37
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts
@@ -0,0 +1,123 @@
+import { z } from 'zod';
+
+import { promotionDisplayNameSchema } from './promotion-common.dto';
+import {
+ UpsertPromotionApplyConfigDto,
+ UpsertPromotionPromoteConfigDto,
+ promotionConnectionConfigsPublicSchema,
+} from './promotion-config.dto';
+import { promotionProviderSummarySchema } from './promotion-provider.dto';
+import { Z } from '../../zod-class';
+import { publicApiPaginationSchema } from '../pagination/pagination.dto';
+
+/**
+ * `instance` covers the whole instance. `projects` covers the projects linked to
+ * the connection. Only one instance connection can exist.
+ */
+export const promotionConnectionScopeSchema = z.enum(['instance', 'projects']);
+export type PromotionConnectionScope = z.infer;
+
+/**
+ * Checks the shape only. The backend checks the URL itself: allowed protocols,
+ * accepted SSH forms, and no credentials in the URL.
+ */
+const remoteUrlSchema = z.string().trim().min(1);
+
+/**
+ * Plain Git points at a repository with its full SSH or HTTP(S) remote URL. A new
+ * provider type adds its own target here and needs no remote URL.
+ */
+export const promotionConnectionTargetSchema = z
+ .object({ schemaVersion: z.literal(1), remoteUrl: remoteUrlSchema })
+ .strict();
+export type PromotionConnectionTarget = z.infer;
+
+export class CreatePromotionConnectionDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema,
+ scope: promotionConnectionScopeSchema,
+ /** Providers are created on their own route. A connection only picks one. */
+ providerId: z.string().min(1),
+ target: promotionConnectionTargetSchema,
+ /**
+ * Initial configs, keyed by direction, reusing the bodies of the config
+ * routes. Keying makes "one per direction" structural. A connection can start
+ * with no configs, which lets an admin read the provider's public key before
+ * the remote can be cloned.
+ */
+ configs: z
+ .object({
+ apply: UpsertPromotionApplyConfigDto.schema.optional(),
+ promote: UpsertPromotionPromoteConfigDto.schema.optional(),
+ })
+ .strict()
+ .optional(),
+ },
+ { strict: true },
+) {}
+
+/**
+ * Connection fields only. `scope` cannot change, and configs, project links, and
+ * package operations each have their own route, so a strict shape rejects them
+ * here.
+ */
+export class UpdatePromotionConnectionDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema.optional(),
+ target: promotionConnectionTargetSchema.optional(),
+ providerId: z.string().min(1).optional(),
+ },
+ { strict: true },
+) {}
+
+/**
+ * The `providerId` filter shows which connections a provider edit affects. The
+ * instance settings page asks for `scope=instance` rather than taking whichever
+ * connection comes first.
+ */
+export class ListPromotionConnectionsQueryDto extends Z.class({
+ limit: publicApiPaginationSchema.limit,
+ cursor: z.string().optional(),
+ scope: promotionConnectionScopeSchema.optional(),
+ providerId: z.string().optional(),
+}) {}
+
+/**
+ * There are at most two configs, so they are embedded instead of paginated, keyed
+ * by direction to match the request side.
+ *
+ * The provider is embedded as a summary, which names it but leaves out its public
+ * config. Detail and list share this schema, so the full form would put the SSH
+ * public key in every row of the connection list. Read the key from the provider
+ * detail route.
+ */
+export const promotionConnectionPublicSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ scope: promotionConnectionScopeSchema,
+ target: promotionConnectionTargetSchema,
+ provider: promotionProviderSummarySchema,
+ configs: promotionConnectionConfigsPublicSchema,
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+});
+
+export class PromotionConnectionPublicDto extends Z.class(promotionConnectionPublicSchema.shape) {}
+
+export class PromotionConnectionListPublicDto extends Z.class({
+ data: z.array(promotionConnectionPublicSchema),
+ nextCursor: z.string().nullable(),
+}) {}
+
+export const promotionConnectionProjectPublicSchema = z.object({
+ projectId: z.string(),
+ connectionId: z.string(),
+});
+
+export class PromotionConnectionProjectPublicDto extends Z.class(
+ promotionConnectionProjectPublicSchema.shape,
+) {}
+
+export class PromotionConnectionProjectListPublicDto extends Z.class({
+ projectIds: z.array(z.string()),
+}) {}
diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts
new file mode 100644
index 00000000000..503023abfdf
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts
@@ -0,0 +1,89 @@
+import { z } from 'zod';
+
+import { Z } from '../../zod-class';
+
+export class PromotePackageDto extends Z.class(
+ {
+ commitMessage: z.string().trim().min(1).max(1000),
+ force: z.boolean().optional(),
+ },
+ { strict: true },
+) {}
+
+/** The Git part of an operation result. A new transport returns its own instead. */
+export const promotionGitResultSchema = z.object({
+ commitSha: z.string(),
+ /**
+ * For Promote, the branch it pushed to. That is the configured base branch for a
+ * direct push, or the new promotion branch once branching lands. For Apply, the
+ * branch the package came from.
+ */
+ branchName: z.string(),
+});
+
+const count = () => z.number().int().nonnegative();
+
+/** What ended up in the exported package, after folders and auto-inclusion. */
+export const promotePackageCountsSchema = z.object({
+ workflows: count(),
+ folders: count(),
+ credentials: count(),
+ dataTables: count(),
+ variables: count(),
+ tags: count(),
+});
+
+/** Describes the instance that handled the request, not any other instance. */
+export const promotePackageResultSchema = z.object({
+ connectionId: z.string(),
+ configId: z.string(),
+ counts: promotePackageCountsSchema,
+ git: promotionGitResultSchema,
+});
+
+export class PromotePackageResultDto extends Z.class(promotePackageResultSchema.shape) {}
+
+/** What the import changed. Partial results are visible here too. */
+export const applyPackageCountsSchema = z.object({
+ projects: z.object({ created: count(), updated: count(), skipped: count(), deleted: count() }),
+ folders: z.object({ created: count(), skipped: count(), removed: count() }),
+ workflows: z.object({
+ created: count(),
+ updated: count(),
+ skipped: count(),
+ archived: count(),
+ deleted: count(),
+ publishing: z.object({
+ published: count(),
+ unpublished: count(),
+ unchanged: count(),
+ blocked: count(),
+ failed: count(),
+ }),
+ }),
+ credentials: z.object({ matched: count(), stubbed: count() }),
+ dataTables: z.object({ matched: count(), created: count() }),
+ variables: z.object({
+ matched: count(),
+ created: count(),
+ updated: count(),
+ stubbed: count(),
+ missing: count(),
+ }),
+ tags: z.object({
+ matched: count(),
+ created: count(),
+ renamed: count(),
+ reconciled: count(),
+ skipped: count(),
+ }),
+});
+
+export const applyPackageResultSchema = z.object({
+ connectionId: z.string(),
+ configId: z.string(),
+ counts: applyPackageCountsSchema,
+ git: promotionGitResultSchema,
+});
+
+export class ApplyPackageResultDto extends Z.class(applyPackageResultSchema.shape) {}
diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts
new file mode 100644
index 00000000000..790c221c6e0
--- /dev/null
+++ b/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts
@@ -0,0 +1,177 @@
+import { z } from 'zod';
+
+import { promotionDisplayNameSchema } from './promotion-common.dto';
+import { Z } from '../../zod-class';
+import { publicApiPaginationSchema } from '../pagination/pagination.dto';
+
+export const promotionProviderTypeSchema = z.enum(['git']);
+export type PromotionProviderType = z.infer;
+
+/** `token` is an HTTP(S) username and password, not a Git host API token. */
+export const promotionProviderAuthTypeSchema = z.enum(['ssh-key', 'token']);
+export type PromotionProviderAuthType = z.infer;
+
+/** Key algorithms the backend can generate for an `ssh-key` provider. */
+export const promotionSshKeyTypeSchema = z.enum(['ed25519', 'rsa']);
+export type PromotionSshKeyType = z.infer;
+
+/**
+ * The backend writes the public key and key type when it generates the key pair.
+ * No request schema accepts them.
+ */
+export const promotionGitSshKeyConfigSchema = z
+ .object({
+ schemaVersion: z.literal(1),
+ publicKey: z.string().min(1),
+ keyType: promotionSshKeyTypeSchema,
+ })
+ .strict();
+
+/** HTTP(S) keeps the username with the password, so there is nothing public. */
+export const promotionGitTokenConfigSchema = z.object({ schemaVersion: z.literal(1) }).strict();
+
+/**
+ * Config schema for each auth type. Use this map once the auth type is known: the
+ * union below accepts either shape and cannot tell them apart. A new provider type
+ * adds its own map.
+ */
+export const promotionGitConfigSchemas = {
+ 'ssh-key': promotionGitSshKeyConfigSchema,
+ token: promotionGitTokenConfigSchema,
+} as const;
+
+export const promotionProviderConfigSchema = z.union([
+ promotionGitSshKeyConfigSchema,
+ promotionGitTokenConfigSchema,
+]);
+export type PromotionProviderConfig = z.infer;
+
+// Trim the username, so a padded value is never saved.
+const promotionUsernameSchema = z.string().trim().min(1);
+// Do not trim the password. A token is saved exactly as sent, and a blank one is rejected.
+const promotionSecretSchema = z.string().min(1);
+
+/*
+ * The auth payload carries its own `authType`, so it is a discriminated union and
+ * a request cannot pair one auth type with another's fields. Keeping the
+ * discriminator inside `auth` rather than beside it is what lets the request body
+ * stay a plain object: only a field can be a union here, not the body root.
+ *
+ * Responses have no `auth` at all, so they report `authType` at the top level.
+ */
+
+/** The backend generates the key pair. The caller only picks the algorithm. */
+export const promotionGitSshKeyAuthInputSchema = z
+ .object({
+ authType: z.literal('ssh-key'),
+ keyType: promotionSshKeyTypeSchema.default('ed25519'),
+ })
+ .strict();
+
+/**
+ * The same payload for an update, but `keyType` has no default. An omitted value
+ * stays omitted, so the service can rotate the key with the algorithm the provider
+ * already uses. A default here would turn an `rsa` provider into an `ed25519` one
+ * on an edit that never mentioned the key.
+ */
+export const promotionGitSshKeyAuthUpdateSchema = z
+ .object({
+ authType: z.literal('ssh-key'),
+ keyType: promotionSshKeyTypeSchema.optional(),
+ })
+ .strict();
+
+/** Username and password are required together, on create and on update. */
+export const promotionGitTokenAuthInputSchema = z
+ .object({
+ authType: z.literal('token'),
+ username: promotionUsernameSchema,
+ password: promotionSecretSchema,
+ })
+ .strict();
+
+export const promotionProviderAuthInputSchema = z.discriminatedUnion('authType', [
+ promotionGitSshKeyAuthInputSchema,
+ promotionGitTokenAuthInputSchema,
+]);
+export type PromotionProviderAuthInput = z.infer;
+
+export const promotionProviderAuthUpdateSchema = z.discriminatedUnion('authType', [
+ promotionGitSshKeyAuthUpdateSchema,
+ promotionGitTokenAuthInputSchema,
+]);
+export type PromotionProviderAuthUpdate = z.infer;
+
+/**
+ * The auth type is stated once, inside `auth`, so it always matches the credentials
+ * beside it. The service reads it from there for the `authType` column.
+ *
+ * `git` is the only provider type today, so its two auth methods are the only ones
+ * the union accepts. A second provider type brings its own auth variants, and the
+ * pairing of provider type to auth method becomes a choice to model here.
+ */
+export class CreatePromotionProviderDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema,
+ type: promotionProviderTypeSchema,
+ auth: promotionProviderAuthInputSchema,
+ },
+ { strict: true },
+) {}
+
+/**
+ * `type` cannot change and `config` holds generated key material, so a strict shape
+ * rejects both. Leaving out `auth` keeps the stored credentials. Sending it replaces
+ * them.
+ *
+ * `auth.authType` states which credentials are being sent, and the auth type itself
+ * cannot change, so the service compares it with the stored one and rejects a
+ * mismatch. It also reads an omitted `keyType` from the provider's current key type,
+ * so rotating an `rsa` key keeps it `rsa`.
+ */
+export class UpdatePromotionProviderDto extends Z.class(
+ {
+ name: promotionDisplayNameSchema.optional(),
+ auth: promotionProviderAuthUpdateSchema.optional(),
+ },
+ { strict: true },
+) {}
+
+/** Leaves out `auth`, which is never returned. */
+export const promotionProviderPublicSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: promotionProviderTypeSchema,
+ authType: promotionProviderAuthTypeSchema,
+ config: promotionProviderConfigSchema,
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+});
+
+export class PromotionProviderPublicDto extends Z.class(promotionProviderPublicSchema.shape) {}
+
+/**
+ * A provider without its public config. Used for list rows and inside a
+ * connection, neither of which shows the SSH public key. Only the provider detail
+ * route does.
+ */
+export const promotionProviderSummarySchema = promotionProviderPublicSchema.omit({ config: true });
+
+export class PromotionProviderListPublicDto extends Z.class({
+ data: z.array(promotionProviderSummarySchema),
+ nextCursor: z.string().nullable(),
+}) {}
+
+/**
+ * Returns the generated public key on its own, so a client does not have to read
+ * the config variant. `token` providers have none.
+ */
+export class PromotionProviderCreatedPublicDto extends Z.class({
+ provider: promotionProviderPublicSchema,
+ publicKey: z.string().nullable(),
+}) {}
+
+export class ListPromotionProvidersQueryDto extends Z.class({
+ limit: publicApiPaginationSchema.limit,
+ cursor: z.string().optional(),
+}) {}
diff --git a/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts b/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts
new file mode 100644
index 00000000000..9e2c22ced70
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts
@@ -0,0 +1,144 @@
+import type { MigrationContext, ReversibleMigration } from '../migration-types';
+
+const PROVIDER_TABLE = 'promotion_provider';
+const CONNECTION_TABLE = 'promotion_connection';
+const CONNECTION_PROJECT_TABLE = 'promotion_connection_project';
+const CONFIG_TABLE = 'promotion_config';
+
+/**
+ * Promotions keeps Git credentials on a provider, the remote to talk to on a
+ * connection, and the settings for one direction in a config. Code validates the
+ * JSON payloads, so a new provider type can reuse these tables.
+ */
+export class CreatePromotionsTables1788759351166 implements ReversibleMigration {
+ async up(context: MigrationContext) {
+ await this.createProviderTable(context);
+ await this.createConnectionTable(context);
+ await this.createConnectionProjectTable(context);
+ await this.createConfigTable(context);
+ }
+
+ async down({ schemaBuilder: { dropTable } }: MigrationContext) {
+ await dropTable(CONFIG_TABLE);
+ await dropTable(CONNECTION_PROJECT_TABLE);
+ await dropTable(CONNECTION_TABLE);
+ await dropTable(PROVIDER_TABLE);
+ }
+
+ private async createProviderTable({ schemaBuilder: { createTable, column } }: MigrationContext) {
+ await createTable(PROVIDER_TABLE).withColumns(
+ column('id').varchar(36).primary,
+ column('name').varchar(128).notNull,
+ column('type')
+ .varchar(32)
+ .notNull.withEnumCheck(['git'])
+ .comment('PromotionProviderType enum: "git"'),
+ column('authType')
+ .varchar(32)
+ .notNull.withEnumCheck(['ssh-key', 'token'])
+ .comment(
+ 'PromotionProviderAuthType enum: "ssh-key", "token". "token" is an HTTP(S) username and password.',
+ ),
+ column('config').json.notNull.comment(
+ 'Non-secret provider settings, with their own schemaVersion. Validated in code.',
+ ),
+ column('auth').text.notNull.comment('Encrypted credentials. Never sent to a client.'),
+ ).withTimestamps;
+ }
+
+ private async createConnectionTable({
+ schemaBuilder: { createTable, createIndex, column },
+ tablePrefix,
+ }: MigrationContext) {
+ await createTable(CONNECTION_TABLE)
+ .withColumns(
+ column('id').varchar(36).primary,
+ column('name').varchar(128).notNull,
+ column('providerId')
+ .varchar(36)
+ .notNull.comment('The provider whose credentials this connection uses.'),
+ column('scope')
+ .varchar(16)
+ .notNull.withEnumCheck(['instance', 'projects'])
+ .comment(
+ 'PromotionConnectionScope enum: "instance", "projects". Cannot change after creation.',
+ ),
+ column('target').json.notNull.comment(
+ 'Where to push and pull, with its own schemaVersion. Plain Git stores the full remote URL.',
+ ),
+ )
+ // RESTRICT, not CASCADE: one provider can serve many connections, so deleting
+ // it must not take their configuration with it.
+ .withTimestamps.withForeignKey('providerId', {
+ tableName: PROVIDER_TABLE,
+ columnName: 'id',
+ onDelete: 'RESTRICT',
+ name: `FK_${tablePrefix}promotion_connection_providerId`,
+ })
+ // Index the foreign key, for listing a provider's connections and for the
+ // check on delete.
+ .withIndexOn(['providerId']);
+
+ // Only one instance connection is allowed, but any number of project ones.
+ // A count check in code would let two slip through at the same time.
+ await createIndex(CONNECTION_TABLE, ['scope'], true, undefined, '"scope" = \'instance\'');
+ }
+
+ private async createConnectionProjectTable({
+ schemaBuilder: { createTable, column },
+ tablePrefix,
+ }: MigrationContext) {
+ // projectId is the primary key, so a project links to one connection at most.
+ // A connection can hold many links. Both foreign keys cascade, so a link goes
+ // away when either side is deleted.
+ await createTable(CONNECTION_PROJECT_TABLE)
+ .withColumns(
+ column('projectId').varchar(36).primary,
+ column('connectionId').varchar(36).notNull,
+ )
+ .withTimestamps.withForeignKey('projectId', {
+ tableName: 'project',
+ columnName: 'id',
+ onDelete: 'CASCADE',
+ name: `FK_${tablePrefix}promotion_connection_project_projectId`,
+ })
+ .withForeignKey('connectionId', {
+ tableName: CONNECTION_TABLE,
+ columnName: 'id',
+ onDelete: 'CASCADE',
+ name: `FK_${tablePrefix}promotion_connection_project_connectionId`,
+ })
+ // Index the foreign key, for listing a connection's projects and for the
+ // cascade delete.
+ .withIndexOn(['connectionId']);
+ }
+
+ private async createConfigTable({
+ schemaBuilder: { createTable, createIndex, column },
+ tablePrefix,
+ }: MigrationContext) {
+ await createTable(CONFIG_TABLE)
+ .withColumns(
+ column('id').varchar(36).primary.comment('Also the name of the local checkout directory.'),
+ column('connectionId').varchar(36).notNull,
+ column('name').varchar(128).notNull,
+ column('direction')
+ .varchar(16)
+ .notNull.withEnumCheck(['apply', 'promote'])
+ .comment('PromotionDirection enum: "apply", "promote". Cannot change after creation.'),
+ column('settings').json.notNull.comment(
+ 'Branch settings for this direction, with their own schemaVersion. Validated in code.',
+ ),
+ )
+ .withTimestamps.withForeignKey('connectionId', {
+ tableName: CONNECTION_TABLE,
+ columnName: 'id',
+ onDelete: 'CASCADE',
+ name: `FK_${tablePrefix}promotion_config_connectionId`,
+ });
+
+ // One config per direction. This index also covers connectionId lookups and the
+ // cascade delete, so connectionId needs no index of its own.
+ await createIndex(CONFIG_TABLE, ['connectionId', 'direction'], true);
+ }
+}
diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts
new file mode 100644
index 00000000000..aedc2e57983
--- /dev/null
+++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts
@@ -0,0 +1,32 @@
+import type { PromotionConfigSettings, PromotionDirection } from '@n8n/api-types';
+import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db';
+import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm';
+
+import { PromotionConnection } from './promotion-connection.entity';
+
+/**
+ * Settings for one direction of one connection. The generated `id` also names the
+ * local checkout directory.
+ */
+@Entity('promotion_config')
+// One config for each direction. This index also covers lookups by connectionId.
+@Index(['connectionId', 'direction'], { unique: true })
+export class PromotionConfig extends WithTimestampsAndStringId {
+ @Column({ type: 'varchar', length: 36 })
+ connectionId: string;
+
+ /** Names are not unique. */
+ @Column({ type: 'varchar', length: 128 })
+ name: string;
+
+ @Column({ type: 'varchar', length: 16 })
+ direction: PromotionDirection;
+
+ /** Branch settings for {@link direction}, with their own `schemaVersion`. */
+ @JsonColumn()
+ settings: PromotionConfigSettings;
+
+ @ManyToOne(() => PromotionConnection, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'connectionId' })
+ connection: Relation;
+}
diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts
new file mode 100644
index 00000000000..d49f02da8ec
--- /dev/null
+++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts
@@ -0,0 +1,36 @@
+import { WithTimestamps } from '@n8n/db';
+import {
+ Column,
+ Entity,
+ Index,
+ JoinColumn,
+ ManyToOne,
+ PrimaryColumn,
+ type Relation,
+} from '@n8n/typeorm';
+
+import { PromotionConnection } from './promotion-connection.entity';
+
+/**
+ * Links a project to a promotion connection. The `projectId` primary key keeps a
+ * project on one connection at most. A connection can hold many links, or none.
+ * Which projects and connections are eligible spans tables, so the service checks
+ * that rather than the schema.
+ *
+ * The foreign key to `project` is declared in the migration and not as a
+ * `@ManyToOne`, to keep this module apart from the core `Project` entity. This
+ * follows the workflow-reviews pattern.
+ */
+@Entity('promotion_connection_project')
+export class PromotionConnectionProject extends WithTimestamps {
+ @PrimaryColumn({ type: 'varchar', length: 36 })
+ projectId: string;
+
+ @Index()
+ @Column({ type: 'varchar', length: 36 })
+ connectionId: string;
+
+ @ManyToOne(() => PromotionConnection, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'connectionId' })
+ connection: Relation;
+}
diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts
new file mode 100644
index 00000000000..06c2a5d67c9
--- /dev/null
+++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts
@@ -0,0 +1,36 @@
+import type { PromotionConnectionScope, PromotionConnectionTarget } from '@n8n/api-types';
+import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db';
+import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm';
+
+import { PromotionProvider } from './promotion-provider.entity';
+
+/**
+ * One remote, reached with one provider's credentials. The provider, remote URL,
+ * and name are not unique, because a provider can serve several connections.
+ */
+@Entity('promotion_connection')
+// Only one instance connection is allowed, but any number of project ones.
+@Index(['scope'], { unique: true, where: '"scope" = \'instance\'' })
+export class PromotionConnection extends WithTimestampsAndStringId {
+ @Column({ type: 'varchar', length: 128 })
+ name: string;
+
+ @Index()
+ @Column({ type: 'varchar', length: 36 })
+ providerId: string;
+
+ /**
+ * Cannot change after creation. A change would quietly move the instance
+ * fallback or break existing project links. Create a new connection instead.
+ */
+ @Column({ type: 'varchar', length: 16 })
+ scope: PromotionConnectionScope;
+
+ /** Where to push and pull, with its own `schemaVersion`. */
+ @JsonColumn()
+ target: PromotionConnectionTarget;
+
+ @ManyToOne(() => PromotionProvider, { onDelete: 'RESTRICT' })
+ @JoinColumn({ name: 'providerId' })
+ provider: Relation;
+}
diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts
new file mode 100644
index 00000000000..86349237b26
--- /dev/null
+++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts
@@ -0,0 +1,36 @@
+import type {
+ PromotionProviderAuthType,
+ PromotionProviderConfig,
+ PromotionProviderType,
+} from '@n8n/api-types';
+import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db';
+import { Column, Entity } from '@n8n/typeorm';
+
+/**
+ * Credentials for a remote, shared by any number of connections. The database
+ * limits which {@link type} and {@link authType} values are allowed.
+ */
+@Entity('promotion_provider')
+export class PromotionProvider extends WithTimestampsAndStringId {
+ @Column({ type: 'varchar', length: 128 })
+ name: string;
+
+ @Column({ type: 'varchar', length: 32 })
+ type: PromotionProviderType;
+
+ /** `token` is an HTTP(S) username and password, not a Git host API token. */
+ @Column({ type: 'varchar', length: 32 })
+ authType: PromotionProviderAuthType;
+
+ /**
+ * Non-secret settings, with their own `schemaVersion`. This type says what we
+ * write, not what an older row holds, so check a loaded row against the schema
+ * for {@link authType}. The same applies to the other JSON columns in this module.
+ */
+ @JsonColumn()
+ config: PromotionProviderConfig;
+
+ /** Encrypted credentials. Never sent to a client. */
+ @Column({ type: 'text' })
+ auth: string;
+}
diff --git a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json
index 4897c3054fc..b0374a3a7b1 100644
--- a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json
+++ b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json
@@ -88,6 +88,11 @@
"name": "GitConnectionProject",
"path": "packages/cli/src/modules/git-connections.ee/database/entities/git-connection-project.entity.ts",
"reason": "Project-scoped git configuration (which connection the project is linked to), not user data. Dropped via the projectId FK cascade when the source project itself is deleted, as in ownership-transfer flows."
+ },
+ {
+ "name": "PromotionConnectionProject",
+ "path": "packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts",
+ "reason": "Promotion configuration for the project (which connection it is linked to), not user data. Dropped via the projectId FK cascade when the source project is deleted, as in ownership-transfer flows. Replaces GitConnectionProject, which goes away with the old tables."
}
]
}
diff --git a/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts b/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts
new file mode 100644
index 00000000000..c5990789f83
--- /dev/null
+++ b/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts
@@ -0,0 +1,307 @@
+import {
+ createTestMigrationContext,
+ initDbUpToMigration,
+ runSingleMigration,
+ type TestMigrationContext,
+} from '@n8n/backend-test-utils';
+import { DbConnection } from '@n8n/db';
+import { Container } from '@n8n/di';
+import { DataSource } from '@n8n/typeorm';
+import { randomUUID } from 'node:crypto';
+
+const MIGRATION_NAME = 'CreatePromotionsTables1788759351166';
+
+const PROVIDER_TABLE = 'promotion_provider';
+const CONNECTION_TABLE = 'promotion_connection';
+const LINK_TABLE = 'promotion_connection_project';
+const CONFIG_TABLE = 'promotion_config';
+
+describe('CreatePromotionsTables migration', () => {
+ let dataSource: DataSource;
+
+ beforeAll(async () => {
+ const dbConnection = Container.get(DbConnection);
+ await dbConnection.init();
+ dataSource = Container.get(DataSource);
+ const context = createTestMigrationContext(dataSource);
+ await context.queryRunner.clearDatabase();
+ await context.queryRunner.release();
+ await initDbUpToMigration(MIGRATION_NAME);
+ await runSingleMigration(MIGRATION_NAME);
+ });
+
+ afterAll(async () => {
+ await Container.get(DbConnection).close();
+ });
+
+ async function insertProvider(
+ context: TestMigrationContext,
+ id: string,
+ overrides: { type?: string; authType?: string } = {},
+ ) {
+ const now = new Date();
+ await context.runQuery(
+ `INSERT INTO ${context.escape.tableName(PROVIDER_TABLE)}
+ ("id", "name", "type", "authType", "config", "auth", "createdAt", "updatedAt")
+ VALUES (:id, :name, :type, :authType, :config, :auth, :createdAt, :updatedAt)`,
+ {
+ id,
+ name: 'Provider',
+ type: overrides.type ?? 'git',
+ authType: overrides.authType ?? 'ssh-key',
+ config: JSON.stringify({
+ schemaVersion: 1,
+ publicKey: 'ssh-ed25519 KEY',
+ keyType: 'ed25519',
+ }),
+ auth: 'encrypted',
+ createdAt: now,
+ updatedAt: now,
+ },
+ );
+ }
+
+ async function insertConnection(
+ context: TestMigrationContext,
+ id: string,
+ providerId: string,
+ scope = 'projects',
+ ) {
+ const now = new Date();
+ await context.runQuery(
+ `INSERT INTO ${context.escape.tableName(CONNECTION_TABLE)}
+ ("id", "name", "providerId", "scope", "target", "createdAt", "updatedAt")
+ VALUES (:id, :name, :providerId, :scope, :target, :createdAt, :updatedAt)`,
+ {
+ id,
+ name: 'Connection',
+ providerId,
+ scope,
+ target: JSON.stringify({ schemaVersion: 1, remoteUrl: 'git@example.com:team/repo.git' }),
+ createdAt: now,
+ updatedAt: now,
+ },
+ );
+ }
+
+ async function insertProject(context: TestMigrationContext, id: string) {
+ const now = new Date();
+ await context.runQuery(
+ `INSERT INTO ${context.escape.tableName('project')}
+ ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt")
+ VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`,
+ { id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now },
+ );
+ }
+
+ async function insertLink(
+ context: TestMigrationContext,
+ projectId: string,
+ connectionId: string,
+ ) {
+ const now = new Date();
+ await context.runQuery(
+ `INSERT INTO ${context.escape.tableName(LINK_TABLE)}
+ ("projectId", "connectionId", "createdAt", "updatedAt")
+ VALUES (:projectId, :connectionId, :createdAt, :updatedAt)`,
+ { projectId, connectionId, createdAt: now, updatedAt: now },
+ );
+ }
+
+ async function insertConfig(
+ context: TestMigrationContext,
+ id: string,
+ connectionId: string,
+ direction: string,
+ ) {
+ const now = new Date();
+ await context.runQuery(
+ `INSERT INTO ${context.escape.tableName(CONFIG_TABLE)}
+ ("id", "connectionId", "name", "direction", "settings", "createdAt", "updatedAt")
+ VALUES (:id, :connectionId, :name, :direction, :settings, :createdAt, :updatedAt)`,
+ {
+ id,
+ connectionId,
+ name: direction,
+ direction,
+ settings: JSON.stringify(
+ direction === 'apply'
+ ? { schemaVersion: 1, branchName: 'dev' }
+ : { schemaVersion: 1, baseBranchName: 'staging', createBranchOnPromotion: false },
+ ),
+ createdAt: now,
+ updatedAt: now,
+ },
+ );
+ }
+
+ async function countRows(
+ context: TestMigrationContext,
+ table: string,
+ where: string,
+ params: object,
+ ) {
+ const rows = await context.runQuery>(
+ `SELECT COUNT(*) as c FROM ${context.escape.tableName(table)} WHERE ${where}`,
+ params,
+ );
+ return Number(rows[0].c);
+ }
+
+ describe('Up migration', () => {
+ it('allows one instance connection but any number of project connections', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, randomUUID(), providerId, 'instance');
+
+ await expect(
+ insertConnection(context, randomUUID(), providerId, 'instance'),
+ ).rejects.toThrow();
+
+ await insertConnection(context, randomUUID(), providerId, 'projects');
+ await insertConnection(context, randomUUID(), providerId, 'projects');
+ expect(
+ await countRows(context, CONNECTION_TABLE, '"scope" = :scope', { scope: 'projects' }),
+ ).toBe(2);
+ await context.queryRunner.release();
+ });
+
+ it('rejects values outside the type, auth type, scope, and direction enums', async () => {
+ const context = createTestMigrationContext(dataSource);
+ await expect(insertProvider(context, randomUUID(), { type: 'gitlab' })).rejects.toThrow();
+ await expect(insertProvider(context, randomUUID(), { authType: 'oauth2' })).rejects.toThrow();
+
+ const providerId = randomUUID();
+ await insertProvider(context, providerId);
+ await expect(
+ insertConnection(context, randomUUID(), providerId, 'workflows'),
+ ).rejects.toThrow();
+
+ const connectionId = randomUUID();
+ await insertConnection(context, connectionId, providerId);
+ await expect(insertConfig(context, randomUUID(), connectionId, 'sync')).rejects.toThrow();
+ await context.queryRunner.release();
+ });
+
+ it('refuses to delete a provider that a connection still uses', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, randomUUID(), providerId);
+
+ await expect(
+ context.runQuery(
+ `DELETE FROM ${context.escape.tableName(PROVIDER_TABLE)} WHERE "id" = :id`,
+ { id: providerId },
+ ),
+ ).rejects.toThrow();
+ await context.queryRunner.release();
+ });
+
+ it('allows one config for each direction and rejects a repeated direction', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ const connectionId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, connectionId, providerId);
+ await insertConfig(context, randomUUID(), connectionId, 'apply');
+ await insertConfig(context, randomUUID(), connectionId, 'promote');
+
+ await expect(insertConfig(context, randomUUID(), connectionId, 'apply')).rejects.toThrow();
+ expect(
+ await countRows(context, CONFIG_TABLE, '"connectionId" = :connectionId', { connectionId }),
+ ).toBe(2);
+ await context.queryRunner.release();
+ });
+
+ it('links a project to one connection at most', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ const connectionId = randomUUID();
+ const otherConnectionId = randomUUID();
+ const projectId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, connectionId, providerId);
+ await insertConnection(context, otherConnectionId, providerId);
+ await insertProject(context, projectId);
+ await insertLink(context, projectId, connectionId);
+
+ await expect(insertLink(context, projectId, otherConnectionId)).rejects.toThrow();
+ await context.queryRunner.release();
+ });
+
+ it('deletes the configs and links of a deleted connection, but keeps its provider', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ const connectionId = randomUUID();
+ const projectId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, connectionId, providerId);
+ await insertProject(context, projectId);
+ await insertLink(context, projectId, connectionId);
+ await insertConfig(context, randomUUID(), connectionId, 'apply');
+
+ await context.runQuery(
+ `DELETE FROM ${context.escape.tableName(CONNECTION_TABLE)} WHERE "id" = :id`,
+ { id: connectionId },
+ );
+
+ expect(
+ await countRows(context, CONFIG_TABLE, '"connectionId" = :connectionId', { connectionId }),
+ ).toBe(0);
+ expect(await countRows(context, LINK_TABLE, '"projectId" = :projectId', { projectId })).toBe(
+ 0,
+ );
+ expect(await countRows(context, PROVIDER_TABLE, '"id" = :id', { id: providerId })).toBe(1);
+ await context.queryRunner.release();
+ });
+
+ it('deletes only the link of a deleted project, not the connection', async () => {
+ const context = createTestMigrationContext(dataSource);
+ const providerId = randomUUID();
+ const connectionId = randomUUID();
+ const projectId = randomUUID();
+ await insertProvider(context, providerId);
+ await insertConnection(context, connectionId, providerId);
+ await insertProject(context, projectId);
+ await insertLink(context, projectId, connectionId);
+
+ await context.runQuery(
+ `DELETE FROM ${context.escape.tableName('project')} WHERE "id" = :id`,
+ { id: projectId },
+ );
+
+ expect(await countRows(context, LINK_TABLE, '"projectId" = :projectId', { projectId })).toBe(
+ 0,
+ );
+ expect(await countRows(context, CONNECTION_TABLE, '"id" = :id', { id: connectionId })).toBe(
+ 1,
+ );
+ await context.queryRunner.release();
+ });
+ });
+
+ describe('Down migration', () => {
+ it('drops the four tables and can be applied again', async () => {
+ const tables = [PROVIDER_TABLE, CONNECTION_TABLE, LINK_TABLE, CONFIG_TABLE];
+ await dataSource.undoLastMigration({ transaction: 'each' });
+
+ const context = createTestMigrationContext(dataSource);
+ for (const table of tables) {
+ expect(await context.queryRunner.hasTable(`${context.tablePrefix}${table}`)).toBe(false);
+ }
+ await context.queryRunner.release();
+
+ await runSingleMigration(MIGRATION_NAME);
+
+ const afterRerun = createTestMigrationContext(dataSource);
+ for (const table of tables) {
+ expect(await afterRerun.queryRunner.hasTable(`${afterRerun.tablePrefix}${table}`)).toBe(
+ true,
+ );
+ }
+ await afterRerun.queryRunner.release();
+ });
+ });
+});