From c258928ab62adc1327913c21556520dfd1e5c24c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 10:09:04 +0200 Subject: [PATCH 1/3] fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) (#9485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems Windmill paths are case-sensitive, but Windows (and the default macOS setup) use case-insensitive filesystems. The real-world failure behind WIN-2020 is not a user authoring both f/Caps and f/caps — it is a single capitalized folder whose on-disk casing silently drifts (Windows stores and reports whatever case the directory was first created with, regardless of the server's path). The diff then sees the drifted local path as a brand-new item and emits a destructive "delete f/Caps + add f/caps" pair, so a capitalized folder appears to vanish and a lowercase clone shows up out of nowhere — and a push can clobber the real server item. Fix: on a case-insensitive filesystem, reconcile case-only drift before diffing. The server's path casing is authoritative, so compareDynFSElement now rewrites local keys that differ from a remote key only by case to the server's casing (canonicalizeCaseInsensitiveKeys), making the diff treat them as the same item. Case-insensitivity is auto-detected by probing the sync directory, with a WMILL_CASE_INSENSITIVE_FS=true/false override to force Windows behaviour (or emulate it for tests / cross-platform repos) on any host. Reconciled paths are summarized in a single info line. Genuinely unrepresentable collisions — two DISTINCT server paths that differ only by case — cannot be canonicalized to one target; those are detected and warned about on every platform so a case-sensitive-Linux author learns their tree won't round-trip for a Windows/macOS teammate. Tests: - Pure unit tests for findCaseInsensitiveCollisions, canonicalizeCaseInsensitiveKeys and summarizeCaseRewrites (platform independent). - An end-to-end drift test that runs on BOTH CI jobs: on the Windows runner it exercises the real case-insensitive NTFS + auto-probe; on Linux it reproduces the drift via rename, asserts the destructive phantom appears without the fix, and asserts a clean no-op push with the fix forced on. Fixes WIN-2020 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): canonicalize local-only descendants of drifted folders; dedupe nested case collisions Address two review findings on the WIN-2020 case-insensitive sync fix: P1 (correctness): canonicalizeCaseInsensitiveKeys previously only rewrote local keys with an exact full-path remote match. A brand-new local file under a drifted folder (e.g. adding f/caps/New.ts when the server has f/Caps but no f/caps/New.ts) had no exact match, so it kept its lowercase casing and push uploaded it as-is — recreating f/caps beside f/Caps and reintroducing the very collision the fix prevents. Canonicalization is now segment-by-segment against a trie of remote paths, so local-only descendants inherit the longest unambiguous server folder casing. A segment is only adopted when the server casing is unambiguous; at the first ambiguous/unknown segment the remainder keeps local casing. The original key's separator style is preserved so rewritten keys still round-trip. P2 (nit): findCaseInsensitiveCollisions reported the folder group AND a nested per-file group when case-variant folders held same-named files, inflating the "Found N path(s)" count. It now reports only the shallowest clash (drops a group whose ancestor prefix is itself a collision). Tests: add unit coverage for the new-file-under-drifted-folder rewrite, the stop-at-first-unguided-segment behavior, and shallowest-only collision reporting; extend the e2e drift test to assert a new item added under the drifted folder is pushed under the server's folder casing. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cli/src/commands/sync/sync.ts | 286 +++++++++++++++++- .../case_insensitive_collisions_unit.test.ts | 232 ++++++++++++++ cli/test/mixed_case_paths.test.ts | 169 ++++++++++- 3 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 cli/test/case_insensitive_collisions_unit.test.ts diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index ed3b7828f1..863ccb4142 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1836,6 +1836,247 @@ export interface Skips { includeKey?: boolean | undefined; } +// Detect paths (and their parent directories) within a single set that differ +// only by letter case — e.g. a remote workspace that genuinely holds both +// f/Caps/a and f/caps/b. On a case-insensitive filesystem (Windows, default +// macOS) these cannot be represented as two distinct files/directories at all, +// so we can only warn. Returns one group per collision, each listing the +// distinct casings sorted for stable output. +// +// Only the shallowest clash is reported: when two case-variant folders also +// contain same-named files (f/Caps/main.ts + f/caps/main.ts), the folder clash +// is the root cause, so the nested per-file group is suppressed rather than +// inflating the count with one entry per duplicated leaf. +export function findCaseInsensitiveCollisions( + paths: Iterable, +): string[][] { + // lowercased prefix -> set of distinct original casings observed + const byLower = new Map>(); + for (const full of paths) { + // Compare on normalized forward-slash prefixes so a Windows-style "\" map + // key and a remote "/" key collapse to the same prefix. + const segs = full.split(/[\\/]/).filter((s) => s.length > 0); + let acc = ""; + for (let i = 0; i < segs.length; i++) { + // Accumulate every directory prefix as well as the full file path, so a + // "f/Caps" vs "f/caps" folder clash is caught even when the leaf files + // (e.g. a.ts vs b.ts) don't themselves collide. + acc = i === 0 ? segs[i] : `${acc}/${segs[i]}`; + const lower = acc.toLowerCase(); + let set = byLower.get(lower); + if (!set) { + set = new Set(); + byLower.set(lower, set); + } + set.add(acc); + } + } + const collidingLowers = new Set(); + for (const [lower, set] of byLower) { + if (set.size > 1) collidingLowers.add(lower); + } + const collisions: string[][] = []; + for (const lower of collidingLowers) { + // Drop this group if any ancestor prefix is itself a collision — the + // shallower folder clash already names the root cause. + const parts = lower.split("/"); + let hasCollidingAncestor = false; + for (let i = 1; i < parts.length; i++) { + if (collidingLowers.has(parts.slice(0, i).join("/"))) { + hasCollidingAncestor = true; + break; + } + } + if (!hasCollidingAncestor) { + collisions.push([...byLower.get(lower)!].sort()); + } + } + return collisions; +} + +type CaseTrieNode = { + // lowercased segment -> child, recording the canonical (server) casing and + // whether the server holds more than one casing of that segment (ambiguous). + children: Map< + string, + { canonical: string; ambiguous: boolean; node: CaseTrieNode } + >; +}; + +// Rewrite `localMap` keys to the canonical casing recorded on the server +// (`remoteMap`) when they differ only by letter case. This is the core +// WIN-2020 fix: on a case-insensitive filesystem a folder such as `f/Caps` +// can have its on-disk casing silently drift (e.g. to `f/caps`) — Windows +// stores whatever case the directory was first created with and reports that +// from readdir, regardless of the server's path. Without this, the diff sees +// the drifted local path as an entirely different item and emits a destructive +// "delete f/Caps + add f/caps" pair, so a single capitalized folder appears to +// vanish and a lowercase clone shows up out of nowhere. Adopting the server +// casing collapses that phantom and leaves the canonical path on the server +// untouched. +// +// Canonicalization is segment-by-segment against a trie of remote paths, so it +// also applies the server's folder casing to brand-new local files that have no +// exact remote match (e.g. adding f/caps/New.ts under a drifted f/Caps folder +// becomes f/Caps/New.ts) — otherwise the push would recreate the case-only +// collision the fix is meant to prevent. A segment is only adopted when the +// server casing is unambiguous; at the first ambiguous or unknown segment the +// remainder of the path keeps its local casing. +// +// Returns the rewritten map, the per-key rewrites, and any genuinely ambiguous +// server-side groups (two distinct remote paths differing only by case) — those +// can't be canonicalized to a single target and are left for the caller to warn +// about. +export function canonicalizeCaseInsensitiveKeys( + localMap: Record, + remoteMap: Record, +): { + map: Record; + ambiguous: string[][]; + rewritten: { from: string; to: string }[]; +} { + const root: CaseTrieNode = { children: new Map() }; + for (const k of Object.keys(remoteMap)) { + let node = root; + for (const seg of k.split(/[\\/]/)) { + if (seg.length === 0) continue; + const lk = seg.toLowerCase(); + let entry = node.children.get(lk); + if (!entry) { + entry = { canonical: seg, ambiguous: false, node: { children: new Map() } }; + node.children.set(lk, entry); + } else if (entry.canonical !== seg) { + entry.ambiguous = true; + } + node = entry.node; + } + } + + const out: Record = {}; + const rewritten: { from: string; to: string }[] = []; + for (const [k, v] of Object.entries(localMap)) { + // Preserve the key's own separator style so the rewritten key still matches + // the rest of the map (and round-trips through push) on every platform. + const sep = k.includes("\\") ? "\\" : "/"; + const segs = k.split(/[\\/]/); + const canonSegs: string[] = []; + let node: CaseTrieNode | undefined = root; + let changed = false; + for (const seg of segs) { + if (seg.length === 0) { + canonSegs.push(seg); + continue; + } + const entry = node?.children.get(seg.toLowerCase()); + if (entry && !entry.ambiguous) { + if (entry.canonical !== seg) changed = true; + canonSegs.push(entry.canonical); + node = entry.node; + } else { + // No unambiguous server guidance for this segment: keep the local + // casing here and below (deeper server structure is unknown). + canonSegs.push(seg); + node = undefined; + } + } + const canonKey = canonSegs.join(sep); + if (changed && canonKey !== k) { + out[canonKey] = v; + rewritten.push({ from: k, to: canonKey }); + } else { + out[k] = v; + } + } + + return { + map: out, + ambiguous: findCaseInsensitiveCollisions(Object.keys(remoteMap)), + rewritten, + }; +} + +// Summarize case-only key rewrites by their differing path prefix (typically a +// folder such as f/caps -> f/Caps) so a folder whose casing drifted is reported +// once instead of once per contained file. +export function summarizeCaseRewrites( + rewritten: { from: string; to: string }[], +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const { from, to } of rewritten) { + const fromSegs = from.split(/[\\/]/); + const toSegs = to.split(/[\\/]/); + // Find the shortest prefix at which the two casings first differ; that is + // the folder (or file) whose casing actually changed. + let i = 0; + while ( + i < fromSegs.length && + i < toSegs.length && + fromSegs[i] === toSegs[i] + ) { + i++; + } + const fromPrefix = fromSegs.slice(0, i + 1).join("/"); + const toPrefix = toSegs.slice(0, i + 1).join("/"); + const key = `${fromPrefix} -> ${toPrefix}`; + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; +} + +// Emit a single grouped warning for case-only collisions that cannot be +// represented on a case-insensitive filesystem (two distinct server paths +// differing only by case). Unlike the drift handled by +// canonicalizeCaseInsensitiveKeys, these require the user to rename one side. +function warnUnrepresentableCaseCollisions(collisions: string[][]): void { + if (collisions.length === 0) return; + const groups = collisions.map((g) => ` - ${g.join(" <-> ")}`).join("\n"); + log.warn( + `Found ${collisions.length} path(s) that differ only by letter case:\n` + + `${groups}\n` + + `On case-insensitive filesystems (Windows, default macOS) these collapse ` + + `into a single file/directory and cannot both be synced. Rename one side ` + + `to a distinct path to make the tree sync reliably across platforms.`, + ); +} + +// Probe (and cache) whether `dir` lives on a case-insensitive filesystem. +// Auto-detected by round-tripping a probe file under two casings, with an +// explicit WMILL_CASE_INSENSITIVE_FS=true/false override so Windows behaviour +// can be forced (or emulated for cross-platform repos / tests) on any host. +let _caseInsensitiveFsCache: boolean | undefined; +export async function isCaseInsensitiveFilesystem( + dir: string, +): Promise { + const override = (process.env.WMILL_CASE_INSENSITIVE_FS ?? "") + .trim() + .toLowerCase(); + if (override === "true" || override === "1") return true; + if (override === "false" || override === "0") return false; + if (_caseInsensitiveFsCache !== undefined) return _caseInsensitiveFsCache; + let result = false; + try { + const upper = path.join(dir, `.wmill-CASEPROBE-${process.pid}.tmp`); + const lower = path.join(dir, `.wmill-caseprobe-${process.pid}.tmp`); + await writeFile(upper, "", "utf-8"); + try { + await stat(lower); + result = true; // lowercase name resolves to the file we wrote uppercase + } catch { + result = false; + } + await rm(upper).catch(() => {}); + await rm(lower).catch(() => {}); + } catch { + result = false; + } + _caseInsensitiveFsCache = result; + return result; +} + async function compareDynFSElement( els1: DynFSElement, els2: DynFSElement | undefined, @@ -1848,14 +2089,55 @@ async function compareDynFSElement( specificItems?: SpecificItemsConfig, branchOverride?: string, isEls1Remote?: boolean, + caseInsensitiveFs?: boolean, ): Promise<{ changes: Change[]; localMap: Record }> { - const [m1, m2] = els2 + let [m1, m2] = els2 ? await Promise.all([ elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), elementsToMap(els2, ignore, json, skips, specificItems, branchOverride, !isEls1Remote), ]) : [await elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), {}]; + // Reconcile letter-case differences between the local tree and the + // authoritative server casing. Only meaningful for an actual two-sided diff + // (els2 defined) where we know which side is the remote. + if (els2 && isEls1Remote !== undefined) { + const remoteMap = isEls1Remote ? m1 : m2; + + // Always warn about server paths that differ only by case (e.g. f/Caps and + // f/caps as two distinct items). These cannot coexist on a case-insensitive + // filesystem, so flag them on every platform — a Linux author needs to know + // their tree won't round-trip for a Windows/macOS teammate. + warnUnrepresentableCaseCollisions( + findCaseInsensitiveCollisions(Object.keys(remoteMap)), + ); + + // On a case-insensitive filesystem, the local on-disk casing of a folder + // can drift from the server's (Windows reports the case the directory was + // first created with). Rewrite those drifted local keys to the server + // casing so the diff treats them as the same item instead of a destructive + // delete+add pair. This is the WIN-2020 fix. + if (caseInsensitiveFs) { + const { map, rewritten } = canonicalizeCaseInsensitiveKeys( + isEls1Remote ? m2 : m1, + remoteMap, + ); + if (isEls1Remote) { + m2 = map; + } else { + m1 = map; + } + const summary = summarizeCaseRewrites(rewritten); + if (summary.length > 0) { + log.info( + `Reconciled ${summary.length} local path(s) to the server's casing ` + + `(case-insensitive filesystem):\n` + + summary.map((s) => ` ${s}`).join("\n"), + ); + } + } + } + const changes: Change[] = []; function parseYaml(k: string, v: string) { @@ -2613,6 +2895,7 @@ export async function pull( specificItems, wsNameForFiles, true, // els1 (remote) is the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); log.info( @@ -3361,6 +3644,7 @@ export async function push( specificItems, wsNameForFiles, false, // els1 (local) is not the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); // Detect resources/variables that the local config flags as ws_specific diff --git a/cli/test/case_insensitive_collisions_unit.test.ts b/cli/test/case_insensitive_collisions_unit.test.ts new file mode 100644 index 0000000000..511a5975ca --- /dev/null +++ b/cli/test/case_insensitive_collisions_unit.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from "bun:test"; + +import { + findCaseInsensitiveCollisions, + canonicalizeCaseInsensitiveKeys, + summarizeCaseRewrites, +} from "../src/commands/sync/sync.ts"; + +// ============================================================================= +// Case-insensitive sync handling (WIN-2020) +// +// Windmill paths are case-sensitive, but Windows and the default macOS setup +// use case-insensitive filesystems. The real-world failure is NOT a user +// deliberately authoring both f/Caps and f/caps — it is a single capitalized +// folder whose on-disk casing drifts (Windows reports the case the directory +// was first created with), so the diff sees a brand-new lowercase path and a +// destructive delete of the real one. These tests pin: +// 1. findCaseInsensitiveCollisions — warn about genuinely unrepresentable +// server-side collisions (two distinct remote paths differing only by +// case). +// 2. canonicalizeCaseInsensitiveKeys — the fix: rewrite drifted local keys +// to the server's casing so no phantom delete+add is produced. +// ============================================================================= + +function normalize(groups: string[][]): string[][] { + return groups + .map((g) => [...g]) + .sort((a, b) => a.join().localeCompare(b.join())); +} + +// --------------------------------------------------------------------------- +// findCaseInsensitiveCollisions +// --------------------------------------------------------------------------- + +test("collision detection: sibling folders differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: leaf files differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/team/Report.script.ts", + "f/team/report.script.ts", + ]); + expect(normalize(collisions)).toEqual([ + ["f/team/Report.script.ts", "f/team/report.script.ts"], + ]); +}); + +test("collision detection: none for case-consistent distinct paths", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/foo.script.ts", + "f/caps/bar.script.ts", + "f/other/baz.script.ts", + ]); + expect(collisions).toEqual([]); +}); + +test("collision detection: normalizes mixed forward/back slashes", () => { + const collisions = findCaseInsensitiveCollisions([ + "f\\Caps\\a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: reports only the shallowest clash for same-named leaves", () => { + // Two case-variant folders that ALSO hold a same-named file must report the + // single folder clash, not the folder group plus a nested per-file group. + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/main.script.ts", + "f/caps/main.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: groups three distinct casings together", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/a.script.ts", + "f/CAPS/b.script.ts", + "f/Caps/c.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/CAPS", "f/Caps", "f/caps"]]); +}); + +// --------------------------------------------------------------------------- +// canonicalizeCaseInsensitiveKeys (the WIN-2020 fix) +// --------------------------------------------------------------------------- + +test("canonicalize: drifted local folder casing adopts the server casing", () => { + // Server (remote) is authoritative: f/Caps. The local tree drifted to + // f/caps on a case-insensitive FS. + const remote = { + "f/Caps/x.script.ts": "content", + "f/Caps/x.script.yaml": "meta", + }; + const local = { + "f/caps/x.script.ts": "content", + "f/caps/x.script.yaml": "meta", + }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + // Local keys are rewritten to the server casing, so a subsequent exact-key + // diff sees identical paths — no phantom delete+add. + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/Caps/x.script.yaml", + ]); + expect(ambiguous).toEqual([]); + expect(rewritten).toEqual([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + ]); +}); + +test("canonicalize: preserves content values while rewriting keys", () => { + const remote = { "f/MyFolder/Script.script.ts": "remote" }; + const local = { "f/myfolder/Script.script.ts": "LOCAL EDIT" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(map["f/MyFolder/Script.script.ts"]).toEqual("LOCAL EDIT"); + expect(map["f/myfolder/Script.script.ts"]).toBeUndefined(); +}); + +test("canonicalize: leaves keys with no case-insensitive remote match", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { + "f/Caps/x.script.ts": "a", + "f/brand_new/y.script.ts": "b", // genuinely local-only add + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(rewritten).toEqual([]); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/brand_new/y.script.ts", + ]); +}); + +test("canonicalize: does NOT rewrite when the server casing is ambiguous", () => { + // The server itself holds two paths differing only by case — we must not + // silently pick one. Leave the local key untouched and report the ambiguity. + const remote = { + "f/Caps/x.script.ts": "a", + "f/caps/x.script.ts": "b", + }; + const local = { "f/CAPS/x.script.ts": "local" }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(Object.keys(map)).toEqual(["f/CAPS/x.script.ts"]); + // Reported as the shallowest (folder) clash, not the nested per-file group. + expect(normalize(ambiguous)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("canonicalize: new local file under a drifted folder adopts the server folder casing", () => { + // Regression for the P1 review finding: a brand-new local file has no exact + // remote match, but it still lives under a folder whose casing drifted. It + // must inherit the server's folder casing (f/Caps), otherwise push would + // create f/caps/New beside f/Caps/* and reintroduce the case-only collision. + const remote = { "f/Caps/Existing.script.ts": "remote" }; + const local = { + "f/caps/Existing.script.ts": "remote", // drifted, existing + "f/caps/New.script.ts": "brand new", // drifted folder, local-only file + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/Existing.script.ts", + "f/Caps/New.script.ts", + ]); + expect(map["f/Caps/New.script.ts"]).toEqual("brand new"); + expect(rewritten).toContainEqual({ + from: "f/caps/New.script.ts", + to: "f/Caps/New.script.ts", + }); +}); + +test("canonicalize: stops at the first segment with no server guidance", () => { + // Only the folder prefix that exists on the server is canonicalized; deeper + // local-only directories keep their own casing. + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/caps/SubDir/y.script.ts": "b" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map)).toEqual(["f/Caps/SubDir/y.script.ts"]); +}); + +test("canonicalize: identical casing is a no-op", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/Caps/x.script.ts": "a" }; + const { map, rewritten, ambiguous } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(ambiguous).toEqual([]); + expect(map).toEqual({ "f/Caps/x.script.ts": "a" }); +}); + +// --------------------------------------------------------------------------- +// summarizeCaseRewrites +// --------------------------------------------------------------------------- + +test("summarize: collapses per-file rewrites into one folder entry", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + { from: "f/caps/y.script.ts", to: "f/Caps/y.script.ts" }, + ]); + expect(summary).toEqual(["f/caps -> f/Caps"]); +}); + +test("summarize: reports a leaf-file casing change at file granularity", () => { + const summary = summarizeCaseRewrites([ + { from: "f/team/report.script.ts", to: "f/team/Report.script.ts" }, + ]); + expect(summary).toEqual([ + "f/team/report.script.ts -> f/team/Report.script.ts", + ]); +}); + +test("summarize: reports independent folder drifts separately", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "u/alice/y.script.ts", to: "u/Alice/y.script.ts" }, + ]); + expect(summary.sort()).toEqual(["f/caps -> f/Caps", "u/alice -> u/Alice"]); +}); diff --git a/cli/test/mixed_case_paths.test.ts b/cli/test/mixed_case_paths.test.ts index 872c6f94ff..47b1e4e272 100644 --- a/cli/test/mixed_case_paths.test.ts +++ b/cli/test/mixed_case_paths.test.ts @@ -14,7 +14,7 @@ import { expect, test } from "bun:test"; import * as path from "node:path"; -import { writeFile, readFile, stat } from "node:fs/promises"; +import { writeFile, readFile, stat, rename } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -686,6 +686,173 @@ excludes: [] }); }); +// The core WIN-2020 reproduction. The real failure is NOT a user authoring +// both f/Caps and f/caps — it is a SINGLE capitalized folder whose on-disk +// casing drifts on a case-insensitive filesystem (Windows stores/reports the +// case the directory was first created with). The diff then sees a brand-new +// lowercase path plus a destructive delete of the real one. +// +// This test runs on BOTH the Linux and Windows CI jobs: +// - On the Windows runner (real case-insensitive NTFS) the CLI auto-detects +// case-insensitivity via its filesystem probe — no override, real behavior. +// - On Linux (case-sensitive) we reproduce the drift with an explicit rename +// and force the same code path with WMILL_CASE_INSENSITIVE_FS=true, and we +// additionally assert that WITHOUT the fix the destructive phantom appears +// (which can only be observed on a case-sensitive FS). +test("Mixed Case Paths: case-only folder drift reconciles to server casing (WIN-2020)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createScript( + backend, + "f/Caps/MyScript", + 'export async function main() { return "drift repro"; }', + "Drift Script" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const upperDir = path.join(tempDir, "f", "Caps"); + const lowerDir = path.join(tempDir, "f", "caps"); + const pulledExists = await stat(path.join(upperDir, "MyScript.ts")) + .then(() => true) + .catch(() => false); + expect(pulledExists).toBeTruthy(); + + // Simulate the on-disk casing drift (a no-op-in-spirit case-only rename). + // On Windows this is a real case-only rename of the same directory; on + // Linux it produces a genuinely lowercase sibling. + await rename(upperDir, lowerDir); + + const onWindows = process.platform === "win32"; + + // Without case-insensitive handling, the drift is a destructive + // delete(f/Caps) + add(f/caps) phantom. Only observable on a + // case-sensitive FS (Linux), where the override defaults off. + if (!onWindows) { + const buggy = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(buggy.code).toEqual(0); + const buggyChanges = parseJsonFromCLIOutput(buggy.stdout).changes || []; + const hasDelete = buggyChanges.some( + (c: any) => c.type === "deleted" && c.path.replace(/\\/g, "/").startsWith("f/Caps/") + ); + const hasAdd = buggyChanges.some( + (c: any) => c.type === "added" && c.path.replace(/\\/g, "/").startsWith("f/caps/") + ); + expect(hasDelete).toBeTruthy(); + expect(hasAdd).toBeTruthy(); + } + + // With case-insensitive handling in effect (auto on Windows via the FS + // probe, forced via env on Linux) the drifted local casing is reconciled + // to the server's casing, so the push is a clean no-op. + if (!onWindows) process.env.WMILL_CASE_INSENSITIVE_FS = "true"; + try { + const fixed = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(fixed.code).toEqual(0); + const fixedChanges = parseJsonFromCLIOutput(fixed.stdout).changes || []; + if (fixedChanges.length !== 0) { + console.error( + "Expected no changes after reconciliation, got:", + JSON.stringify(fixedChanges, null, 2) + ); + } + expect(fixedChanges.length).toEqual(0); + + // P1 regression: a brand-new item added under the drifted (lowercase) + // folder must be pushed under the server's folder casing (f/Caps), not + // recreate the case-only collision as f/caps. A self-contained variable + // YAML is used so the assertion targets path canonicalization without + // dragging in script lock/metadata generation. + await writeFile( + path.join(lowerDir, "NewVar.variable.yaml"), + `value: hello\nis_secret: false\ndescription: new under drifted folder\nis_oauth: false\n`, + "utf-8" + ); + const added = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(added.code).toEqual(0); + const addedChanges = parseJsonFromCLIOutput(added.stdout).changes || []; + const addedPaths = addedChanges.map((c: any) => + c.path.replace(/\\/g, "/") + ); + expect( + addedPaths.some((p: string) => p === "f/Caps/NewVar.variable.yaml") + ).toBeTruthy(); + expect( + addedPaths.some((p: string) => p.startsWith("f/caps/")) + ).toBeFalsy(); + } finally { + if (!onWindows) delete process.env.WMILL_CASE_INSENSITIVE_FS; + } + }); +}); + +// A genuine, unrepresentable server-side collision: two DISTINCT server folders +// that differ only by case (f/Caps and f/caps). These cannot both exist on a +// case-insensitive filesystem, so the CLI must warn. Skipped on Windows, where +// the pull physically cannot lay both folders down; the warning string itself +// is exercised platform-independently by the unit tests. +const collisionTest = process.platform === "win32" ? test.skip : test; +collisionTest("Mixed Case Paths: distinct server folders differing only by case warn", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createFolder(backend, "caps"); + await createScript( + backend, + "f/Caps/upper", + 'export async function main() { return "upper"; }', + "Upper" + ); + await createScript( + backend, + "f/caps/lower", + 'export async function main() { return "lower"; }', + "Lower" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const combinedOutput = pullResult.stdout + pullResult.stderr; + expect(combinedOutput.includes("differ only by letter case")).toBeTruthy(); + expect(combinedOutput.includes("f/Caps")).toBeTruthy(); + expect(combinedOutput.includes("f/caps")).toBeTruthy(); + }); +}); + test("Mixed Case Paths: CamelCase folder names with numbers", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); From 66c0334e7030a972992c232fb34cc1beba3e8826 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 10:14:02 +0200 Subject: [PATCH 2/3] chore(main): release 1.721.0 (#9480) * chore(main): release 1.721.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 14 ++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 132 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2306e00c..fe1f07c8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.721.0](https://github.com/windmill-labs/windmill/compare/v1.720.0...v1.721.0) (2026-06-09) + + +### Features + +* deployed↔draft compare + AI-session draft bar ([#9435](https://github.com/windmill-labs/windmill/issues/9435)) ([b0b330c](https://github.com/windmill-labs/windmill/commit/b0b330c7864d0159af4b0f17dbb3c09bd015145b)) + + +### Bug Fixes + +* **cli:** reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) ([#9485](https://github.com/windmill-labs/windmill/issues/9485)) ([c258928](https://github.com/windmill-labs/windmill/commit/c258928ab62adc1327913c21556520dfd1e5c24c)) +* drop archived items from fork compare (spurious 'not visible' warning) ([#9481](https://github.com/windmill-labs/windmill/issues/9481)) ([92c21bb](https://github.com/windmill-labs/windmill/commit/92c21bbe6586f3c285796a98692e976515a629d5)) +* require auth to view approval details when user_auth_required ([#9482](https://github.com/windmill-labs/windmill/issues/9482)) ([5f41ddd](https://github.com/windmill-labs/windmill/commit/5f41ddd3a592bcd504f94fc99060ca5d79c36190)) + ## [1.720.0](https://github.com/windmill-labs/windmill/compare/v1.719.0...v1.720.0) (2026-06-08) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 90eb8d984a..a37ee0f26f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13782,7 +13782,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -13864,7 +13864,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.720.0" +version = "1.721.0" dependencies = [ "async-stream", "async-trait", @@ -13897,7 +13897,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13910,7 +13910,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "argon2", @@ -14048,7 +14048,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14071,7 +14071,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14084,7 +14084,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14110,7 +14110,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.720.0" +version = "1.721.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14120,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14137,7 +14137,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14159,7 +14159,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14182,7 +14182,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14198,7 +14198,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14240,7 +14240,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -14289,7 +14289,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14314,7 +14314,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14332,7 +14332,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14354,7 +14354,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14374,7 +14374,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14405,7 +14405,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14433,7 +14433,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.720.0" +version = "1.721.0" dependencies = [ "lazy_static", "serde", @@ -14445,7 +14445,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.720.0" +version = "1.721.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14470,7 +14470,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14484,7 +14484,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14517,7 +14517,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.720.0" +version = "1.721.0" dependencies = [ "chrono", "lazy_static", @@ -14531,7 +14531,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14550,7 +14550,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.720.0" +version = "1.721.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14651,7 +14651,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.720.0" +version = "1.721.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14670,7 +14670,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.720.0" +version = "1.721.0" dependencies = [ "regex", "serde", @@ -14685,7 +14685,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14709,7 +14709,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "futures", @@ -14726,7 +14726,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.720.0" +version = "1.721.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14742,7 +14742,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -14763,7 +14763,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -14794,7 +14794,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "arc-swap", @@ -14819,7 +14819,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-stream", @@ -14853,7 +14853,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "futures", @@ -14871,7 +14871,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.720.0" +version = "1.721.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14880,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -14892,7 +14892,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -14904,7 +14904,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "gosyn", @@ -14916,7 +14916,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -14928,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -14940,7 +14940,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "nu-parser", @@ -14951,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14962,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14974,7 +14974,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14985,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -15019,7 +15019,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15033,7 +15033,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15050,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15063,7 +15063,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -15075,7 +15075,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15093,7 +15093,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15109,7 +15109,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15125,7 +15125,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -15136,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15174,7 +15174,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "const_format", @@ -15212,7 +15212,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.720.0" +version = "1.721.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15223,7 +15223,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.720.0" +version = "1.721.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d702cae2b1..5a9f761a35 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.720.0" +version = "1.721.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.720.0" +version = "1.721.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 96ebbb46c5..97628e2d6e 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.720.0" +version = "1.721.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.720.0" +version = "1.721.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.720.0" +version = "1.721.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8610456c91..4de32388cc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.720.0" +version = "1.721.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 56b1884ed6..a286cce5e4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.720.0 + version: 1.721.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index bd305f80e3..19058c6cb8 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.720.0"; +export const VERSION = "v1.721.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 06f6982e2f..43d063c326 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.720.0"; +export const VERSION = "1.721.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b7a0c6d02..084d5be57f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 07510ec7cc..bb4c3b51f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index f4c78aad4a..e554ae18ca 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.720.0" +wmill = ">=1.721.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 4be0a8b48d..9c4744ee59 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.720.0 + version: 1.721.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 4bfbb2dc9a..aa772756e7 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.720.0' + ModuleVersion = '1.721.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 90e0c68630..0386a40e3e 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.720.0" +version = "1.721.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 6413ffbbdf..ae801c95f5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.720.0", + "version": "1.721.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a5f81e2431..2a38875ecc 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.720.0", + "version": "1.721.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 51ae8492ef..a5f6b5427c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.720.0 +1.721.0 From 136c88a2318659ecd02ffb302fdd71f292c787e7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 16:05:50 +0200 Subject: [PATCH 3/3] docs(skills): decouple safe local commands from destructive sync push (#9467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(skills): decouple safe local commands from destructive sync push The schedules, triggers, and resources skill templates lumped every CLI command under a blunt "do NOT run them yourself" directive. This conflated two very different risk profiles and forbade the agent from running even read-only/local commands, creating needless friction. Align these three with the nuanced policy flow-cli.md already uses: keep `wmill sync push` defensive (it deploys and can be destructive to remote state — only run when the user explicitly asks to deploy/publish/push), while letting read-only commands (`sync pull`, `schedule`, `resource list`) be run freely. Regenerated auto-generated skills + skills.gen.ts. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): warn that sync push is destructive in dry-run output Co-Authored-By: Claude Opus 4.8 (1M context) * docs(skills): clarify sync pull mutates local files, not read-only Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: centdix --- cli/src/commands/sync/sync.ts | 12 ++++++++++++ cli/src/guidance/skills.gen.ts | 11 ++++++----- system_prompts/auto-generated/prompts.ts | 3 ++- .../auto-generated/skills/resources/SKILL.md | 3 ++- .../auto-generated/skills/schedules/SKILL.md | 4 ++-- .../auto-generated/skills/triggers/SKILL.md | 4 ++-- system_prompts/base/resources.md | 3 ++- system_prompts/base/schedules.md | 4 ++-- system_prompts/base/triggers.md | 4 ++-- 9 files changed, 32 insertions(+), 16 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 863ccb4142..10331ed668 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -3465,6 +3465,14 @@ function removeSuffix(str: string, suffix: string) { return str.slice(0, str.length - suffix.length); } +// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push` +// deploys to the remote workspace and is destructive (it overwrites and prunes +// remote items that differ from or are absent locally), so the preview reminds +// the caller — especially an AI agent that ran the dry-run to inspect changes — +// to get explicit user confirmation before applying it for real. +const SYNC_PUSH_DESTRUCTIVE_WARNING = + "`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."; + export async function push( opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string; acceptOverridingPermissionedAsWithSelf?: boolean }, ) { @@ -4001,6 +4009,9 @@ export async function push( : {}), })), total: changes.length, + ...(changes.length > 0 + ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } + : {}), }; console.log(JSON.stringify(result, null, 2)); return; @@ -4044,6 +4055,7 @@ export async function push( if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); + log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`)); return; } diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 61d92755fb..750b0df0ec 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5266,10 +5266,10 @@ Text/HTML/inline parts are placed inline in \`body\` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". \`sync pull\` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview). \`\`\`bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill @@ -5317,10 +5317,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that \`sync pull\` does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview), while \`schedule\` just lists. \`\`\`bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill @@ -5574,7 +5574,8 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push \`\`\` `, diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3d8819c4fe..4a068f2210 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -563,7 +563,8 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push \`\`\` `; diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index 3f78cc1b0b..113826ad63 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -242,6 +242,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push ``` diff --git a/system_prompts/auto-generated/skills/schedules/SKILL.md b/system_prompts/auto-generated/skills/schedules/SKILL.md index 24dab471e5..526740e04c 100644 --- a/system_prompts/auto-generated/skills/schedules/SKILL.md +++ b/system_prompts/auto-generated/skills/schedules/SKILL.md @@ -39,10 +39,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that `sync pull` does overwrite local files to match the remote (use `sync pull --dry-run` to only preview), while `schedule` just lists. ```bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill diff --git a/system_prompts/auto-generated/skills/triggers/SKILL.md b/system_prompts/auto-generated/skills/triggers/SKILL.md index 81401f1fdf..494d5d9798 100644 --- a/system_prompts/auto-generated/skills/triggers/SKILL.md +++ b/system_prompts/auto-generated/skills/triggers/SKILL.md @@ -61,10 +61,10 @@ Text/HTML/inline parts are placed inline in `body` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". `sync pull` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use `sync pull --dry-run` to only preview). ```bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 0f51f6d322..763c0741f8 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -237,6 +237,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push ``` diff --git a/system_prompts/base/schedules.md b/system_prompts/base/schedules.md index 8e50fb87a6..f3d9d9ee9a 100644 --- a/system_prompts/base/schedules.md +++ b/system_prompts/base/schedules.md @@ -34,10 +34,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that `sync pull` does overwrite local files to match the remote (use `sync pull --dry-run` to only preview), while `schedule` just lists. ```bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill diff --git a/system_prompts/base/triggers.md b/system_prompts/base/triggers.md index d97688ca4d..37ce6fc0e6 100644 --- a/system_prompts/base/triggers.md +++ b/system_prompts/base/triggers.md @@ -56,10 +56,10 @@ Text/HTML/inline parts are placed inline in `body` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". `sync pull` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use `sync pull --dry-run` to only preview). ```bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill