From 42543240e87ad232f6dffde61d47cfaced766b20 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 3 Aug 2026 16:23:34 +0000 Subject: [PATCH] fix(cli): stop a deleted raw-app .lock from dropping the whole app push (#10473) * fix(cli): stop a deleted raw-app .lock from dropping the whole app push * test(cli): assert the removed raw-app runnable is gone, trim comments --- cli/src/commands/sync/sync.ts | 14 ++++++-- cli/test/raw_app_sync.test.ts | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 196c4c54e8..2b01fd1ba2 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -5003,6 +5003,10 @@ export async function push( if (deleteRawApp) { changes = [deleteRawApp]; } else { + // The app is one bundle, so a single change re-pushes all of it. + // That leaves the loop exactly one change: any skip it takes for + // a raw-app path drops the whole app from the push, and nothing + // downstream records that as a failure. changes.splice(1, changes.length - 1); } } @@ -5331,8 +5335,14 @@ export async function push( } } else if (change.name === "deleted") { // Same as the added branch: a dbt project's own `.lock` is one of - // its files, so deleting it has to reach the parent script. - if (change.path.endsWith(".lock") && !isDbtModulePath(change.path)) { + // its files, so deleting it has to reach the parent script. A raw + // app's `.lock` is part of its bundle, and a raw-app group is + // collapsed to one change, so skipping it drops the whole app. + if ( + !isRawAppFile(change.path) && + change.path.endsWith(".lock") && + !isDbtModulePath(change.path) + ) { continue; } if (isScriptModulePath(change.path)) { diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index 3cc7026430..315b274d80 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -464,6 +464,74 @@ excludes: []`, "utf-8"); }); }); +test("Raw App: deleted .lock sorting first does not short-circuit the app push", async () => { + // Regression: raw-app changes collapse to one representative change and + // deletes sort first, so removing a backend runnable makes its `.lock` the + // representative. Skipping a `.lock` there drops the whole app while the + // push still reports success. + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "raw_app_lock_first_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: []`, "utf-8"); + + const appDir = path.join(tempDir, "f", "test", "lock_first_app.raw_app"); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); + await createRawAppOnDisk(appDir, true); + + // A backend runnable's lockfile. Among the app's files it sorts first, + // so once deleted it becomes changes[0] for the whole group. + const queryLockPath = path.join(appDir, "backend", "query.lock"); + await writeFile(queryLockPath, INLINE_SCRIPT_A_LOCK, "utf-8"); + + const pushResult1 = await backend.runCLICommand( + ['sync', 'push', '--yes'], + tempDir, "raw_app_lock_first_test" + ); + expect(pushResult1.code).toEqual(0); + await waitForDeploymentJobs(backend); + + // Remove the backend runnable (lock included) and edit a frontend file. + await rm(queryLockPath); + await rm(path.join(appDir, "backend", "query.ts")); + await rm(path.join(appDir, "backend", "query.yaml")); + const appTsxPath = path.join(appDir, "App.tsx"); + const appTsxContent = await readFileContent(appTsxPath); + await writeFile( + appTsxPath, + appTsxContent.replace("hello world", "REGRESSION MARKER"), + "utf-8" + ); + + const pushResult2 = await backend.runCLICommand( + ['sync', 'push', '--yes'], + tempDir, "raw_app_lock_first_test" + ); + expect(pushResult2.code).toEqual(0); + await waitForDeploymentJobs(backend); + + // The edit must have reached the remote bundle, and the removed runnable + // must be gone from it. + const appResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/apps/get/p/f/test/lock_first_app` + ); + expect(appResp.status).toEqual(200); + const appJson = await appResp.json(); + const files = appJson?.value?.files ?? {}; + expect(files["/App.tsx"]).toContain("REGRESSION MARKER"); + // Backend runnables live in value.runnables, not in the bundled files. + expect(appJson?.value?.runnables?.query).toBeUndefined(); + }); +}); + test("Raw App: delete file and push", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace