feat(forks): strip operational state from triggers/schedules on git-sync export

When the source workspace is a fork (`wm-fork-*`), the tarball export now
omits `mode` from triggers and `enabled` from schedules. The trigger update
handler also preserves the existing DB `mode` when both fields are absent
from the request, instead of falling back to the BaseTriggerData default.

This prevents a fork's git-sync round-trip from flipping the parent
workspace's enabled/disabled state when a merge applies the fork's YAML
back to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-04-29 18:35:19 +02:00
parent 4098793db2
commit a4d8f138ae
3 changed files with 115 additions and 13 deletions
+81 -12
View File
@@ -45,6 +45,7 @@ use windmill_common::scripts::ScriptRunnableSettingsHandle;
use windmill_common::utils::require_admin;
use windmill_common::variables::decrypt;
use windmill_common::worker::WINDMILL_DIR;
use windmill_common::workspaces::WM_FORK_PREFIX;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
@@ -119,6 +120,26 @@ pub fn is_none_or_false(val: &Option<bool>) -> bool {
}
}
/// Returns the keys to strip from trigger/schedule serialization when the
/// source workspace is a fork. Stripping these keys avoids propagating
/// fork-local operational state (enabled flag, runtime listener identifiers)
/// back to the parent workspace through the git-sync round-trip.
fn fork_trigger_ignore_keys(w_id: &str) -> Option<Vec<&'static str>> {
if w_id.starts_with(WM_FORK_PREFIX) {
Some(vec!["mode", "enabled"])
} else {
None
}
}
fn fork_schedule_ignore_keys(w_id: &str) -> Option<Vec<&'static str>> {
if w_id.starts_with(WM_FORK_PREFIX) {
Some(vec!["enabled"])
} else {
None
}
}
enum ArchiveImpl {
#[cfg(feature = "zip")]
Zip(async_zip::tokio::write::ZipFileWriter<tokio::fs::File>),
@@ -682,8 +703,11 @@ pub(crate) async fn tarball_workspace(
.fetch_all(&mut *tx)
.await?;
let schedule_ignore_keys = fork_schedule_ignore_keys(&w_id);
for schedule in schedules {
let app_str = &to_string_without_metadata(&schedule, false, None).unwrap();
let app_str =
&to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path))
.await?;
@@ -691,6 +715,27 @@ pub(crate) async fn tarball_workspace(
}
if include_triggers.unwrap_or(false) {
#[cfg(any(
feature = "http_trigger",
feature = "websocket",
feature = "postgres_trigger",
feature = "mqtt_trigger",
feature = "native_trigger",
all(
feature = "enterprise",
any(
feature = "kafka",
feature = "sqs_trigger",
feature = "gcp_trigger",
feature = "azure_trigger",
feature = "nats",
feature = "smtp",
),
feature = "private"
)
))]
let trigger_ignore_keys = fork_trigger_ignore_keys(&w_id);
#[cfg(feature = "http_trigger")]
{
use crate::triggers::http::HttpTrigger;
@@ -698,7 +743,9 @@ pub(crate) async fn tarball_workspace(
let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in http_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -715,7 +762,9 @@ pub(crate) async fn tarball_workspace(
let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in websocket_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -732,7 +781,9 @@ pub(crate) async fn tarball_workspace(
let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in kafka_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -749,7 +800,9 @@ pub(crate) async fn tarball_workspace(
let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in sqs_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -766,7 +819,9 @@ pub(crate) async fn tarball_workspace(
let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in gcp_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -783,7 +838,9 @@ pub(crate) async fn tarball_workspace(
let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in azure_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -801,7 +858,8 @@ pub(crate) async fn tarball_workspace(
for trigger in nats_triggers {
let trigger_str: &String =
&to_string_without_metadata(&trigger, false, None).unwrap();
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -818,7 +876,9 @@ pub(crate) async fn tarball_workspace(
let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in postgres_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -835,7 +895,9 @@ pub(crate) async fn tarball_workspace(
let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in mqtt_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -852,7 +914,9 @@ pub(crate) async fn tarball_workspace(
let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?;
for trigger in email_triggers {
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
let trigger_str =
&to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone())
.unwrap();
archive
.write_to_archive(
&trigger_str,
@@ -872,11 +936,16 @@ pub(crate) async fn tarball_workspace(
list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None)
.await?;
let mut native_ignore_keys = vec!["webhook_token_hash"];
if let Some(ref extra) = trigger_ignore_keys {
native_ignore_keys.extend_from_slice(extra);
}
for trigger in native_triggers {
let trigger_str = &to_string_without_metadata(
&trigger,
false,
Some(vec!["webhook_token_hash"]),
Some(native_ignore_keys.clone()),
)
.unwrap();
archive
+19 -1
View File
@@ -557,7 +557,7 @@ async fn update_trigger<T: TriggerCrud>(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, path)): Path<(String, StripPath)>,
Json(edit_trigger): Json<TriggerData<T::TriggerConfigRequest>>,
Json(mut edit_trigger): Json<TriggerData<T::TriggerConfigRequest>>,
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || {
@@ -574,6 +574,24 @@ async fn update_trigger<T: TriggerCrud>(
let mut tx = user_db.begin(&authed).await?;
// When the request omits `mode`/`enabled`, preserve the existing DB value
// instead of falling back to the BaseTriggerData default (Enabled). This
// keeps fork→parent git-sync round-trips from flipping the parent's
// operational state — see fork_trigger_ignore_keys in workspaces_export.rs.
if edit_trigger.base.is_mode_unspecified() {
let existing_mode: Option<TriggerMode> = sqlx::query_scalar(&format!(
"SELECT mode FROM {} WHERE workspace_id = $1 AND path = $2",
T::TABLE_NAME
))
.bind(&workspace_id)
.bind(path)
.fetch_optional(&mut *tx)
.await?;
if let Some(m) = existing_mode {
edit_trigger.base.set_mode(m);
}
}
let new_path = edit_trigger.base.path.to_string();
let labels = edit_trigger.base.labels.clone();
let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation(
+15
View File
@@ -128,6 +128,21 @@ impl BaseTriggerData {
)
}
/// True when neither `mode` nor the legacy `enabled` field was provided in
/// the request. Used by the update path to distinguish "explicitly Enabled"
/// from "missing — preserve existing value", which matters for git-sync
/// round-trips through fork workspaces (see workspaces_export.rs).
pub fn is_mode_unspecified(&self) -> bool {
#[allow(deprecated)]
{
self.mode.is_none() && self.enabled.is_none()
}
}
pub fn set_mode(&mut self, mode: TriggerMode) {
self.mode = Some(mode);
}
pub fn resolve_permissioned_as(&self, authed: &impl Authable) -> String {
if let Some(ref permissioned_as) = self.permissioned_as {
if self.preserve_permissioned_as.unwrap_or(false)