From af5e2c348a858f4aba2bb223d745476ba2c8dda3 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Tue, 9 Jun 2026 13:04:42 +0200 Subject: [PATCH 1/3] Delete previous deployment after promotion --- e2e-live/deploy.test.ts | 70 +++++++++++++++++++++++++++++++++++++++++ src/api/deploy.test.ts | 44 ++++++++++++++++++++++++-- src/api/deploy.ts | 34 ++++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/e2e-live/deploy.test.ts b/e2e-live/deploy.test.ts index 5866c97..7196755 100644 --- a/e2e-live/deploy.test.ts +++ b/e2e-live/deploy.test.ts @@ -13,6 +13,7 @@ import { pathToFileURL } from "node:url"; import { runInit } from "../src/cli/commands/init.js"; import { runDeploy } from "../src/cli/commands/deploy.js"; import { listDatasources, listPipesV1 } from "../src/api/resources.js"; +import { tinybirdFetch } from "../src/api/fetcher.js"; import { getLiveE2EConfigFromEnv, assertWorkspaceAdminToken, @@ -48,6 +49,12 @@ interface ProductionTinybirdClient { }; } +interface DeploymentListItem { + id: string; + status: string; + live?: boolean; +} + function toTinybirdDateTime(value: Date): string { return value.toISOString().slice(0, 19).replace("T", " "); } @@ -137,6 +144,58 @@ async function waitForEndpointRows( return []; } +async function listDeployments( + config: LiveE2EConfig, + workspaceToken: string, + options?: { includeDeleted?: boolean } +): Promise { + const endpoint = new URL("/v1/deployments", config.baseUrl); + if (options?.includeDeleted) { + endpoint.searchParams.set("include_deleted", "true"); + } + + const response = await tinybirdFetch(endpoint.toString(), { + headers: { + Authorization: `Bearer ${workspaceToken}`, + }, + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + `Failed to list deployments: ${response.status} ${response.statusText} - ${responseText}` + ); + } + + const payload = JSON.parse(responseText) as { deployments?: DeploymentListItem[] }; + return payload.deployments ?? []; +} + +async function waitForDeploymentStatus( + config: LiveE2EConfig, + workspaceToken: string, + deploymentId: string, + expectedStatus: string +): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + const deployments = await listDeployments(config, workspaceToken, { includeDeleted: true }); + const deployment = deployments.find((item) => item.id === deploymentId); + + if (deployment?.status === expectedStatus) { + return deployment; + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + const deployments = await listDeployments(config, workspaceToken, { includeDeleted: true }); + const deployment = deployments.find((item) => item.id === deploymentId); + throw new Error( + `Timed out waiting for deployment ${deploymentId} to become ${expectedStatus}. ` + + `Last status: ${deployment?.status ?? "missing"}` + ); +} + describeLive("E2E Live: deploy", () => { const config = liveConfig as LiveE2EConfig; @@ -233,6 +292,8 @@ describeLive("E2E Live: deploy", () => { const firstDeployResult = await runDeploy({ cwd: tempDir }); expect(firstDeployResult.success).toBe(true); expect(firstDeployResult.deploy?.success).toBe(true); + const firstDeploymentId = firstDeployResult.deploy?.buildId; + expect(firstDeploymentId).toBeTruthy(); const tinybird = await importTinybirdClient(tempDir); const runId = `prod_deploy_second_${Date.now()}`; @@ -259,6 +320,15 @@ describeLive("E2E Live: deploy", () => { expect(secondDeployResult.deploy?.success).toBe(true); expect(secondDeployResult.deploy?.result).toBe("success"); expect(secondDeployResult.deploy?.buildId).toBeTruthy(); + expect(secondDeployResult.deploy?.buildId).not.toBe(firstDeploymentId); + + const previousDeployment = await waitForDeploymentStatus( + config, + workspaceToken, + firstDeploymentId!, + "deleted" + ); + expect(previousDeployment.live).not.toBe(true); const rowsAfterSecondDeploy = await waitForEndpointRows(tinybird, runId); expect(rowsAfterSecondDeploy.length).toBeGreaterThan(0); diff --git a/src/api/deploy.test.ts b/src/api/deploy.test.ts index 816d157..eaf60d9 100644 --- a/src/api/deploy.test.ts +++ b/src/api/deploy.test.ts @@ -294,7 +294,7 @@ describe("Deploy API", () => { expect(parsed.searchParams.get("check")).toBe("true"); }); - it("deletes stale non-live deployments on a normal deploy", async () => { + it("deletes stale non-live deployments before deploy and previous live deployment after promotion", async () => { const deletedIds: string[] = []; server.use( @@ -318,7 +318,47 @@ describe("Deploy API", () => { await deployToMain(config, resources, { pollIntervalMs: 1 }); - expect(deletedIds).toEqual(["stale-1", "stale-2"]); + expect(deletedIds).toEqual(["stale-1", "stale-2", "live-1"]); + }); + + it("deletes the previous live deployment after promoting the new deployment", async () => { + const events: string[] = []; + + server.use( + http.get(`${BASE_URL}/v1/deployments`, () => { + return HttpResponse.json( + createDeploymentsListResponse({ + deployments: [ + { id: "previous-live", status: "live", live: true }, + ], + }) + ); + }), + http.post(`${BASE_URL}/v1/deploy`, () => { + events.push("create"); + return HttpResponse.json( + createDeploySuccessResponse({ deploymentId: "new-deploy", status: "pending" }) + ); + }), + http.get(`${BASE_URL}/v1/deployments/new-deploy`, () => { + return HttpResponse.json( + createDeploymentStatusResponse({ deploymentId: "new-deploy", status: "data_ready" }) + ); + }), + http.post(`${BASE_URL}/v1/deployments/new-deploy/set-live`, () => { + events.push("set-live"); + return HttpResponse.json(createSetLiveSuccessResponse()); + }), + http.delete(`${BASE_URL}/v1/deployments/:id`, ({ params }) => { + events.push(`delete:${params.id as string}`); + return HttpResponse.json({ result: "success" }); + }) + ); + + const result = await deployToMain(config, resources, { pollIntervalMs: 1 }); + + expect(result.success).toBe(true); + expect(events).toEqual(["create", "set-live", "delete:previous-live"]); }); it("adds actionable guidance to Forward/Classic workspace errors", async () => { diff --git a/src/api/deploy.ts b/src/api/deploy.ts index 2b7fcbb..6cdcd06 100644 --- a/src/api/deploy.ts +++ b/src/api/deploy.ts @@ -169,6 +169,7 @@ export async function deployToMain( const pollIntervalMs = options?.pollIntervalMs ?? 1000; const maxPollAttempts = options?.maxPollAttempts ?? 120; // 2 minutes max const baseUrl = config.baseUrl.replace(/\/$/, ""); + let previousLiveDeploymentId: string | undefined; const formData = new FormData(); @@ -230,6 +231,10 @@ export async function deployToMain( if (deploymentsResponse.ok) { const deploymentsBody = (await deploymentsResponse.json()) as DeploymentsListResponse; + const previousLiveDeployment = deploymentsBody.deployments.find( + (d) => d.live || d.status === "live" + ); + previousLiveDeploymentId = previousLiveDeployment?.id; const staleDeployments = deploymentsBody.deployments.filter( (d) => !d.live && d.status !== "live" ); @@ -525,6 +530,35 @@ export async function deployToMain( console.log(`[debug] Deployment ${deploymentId} is now live`); } + if (previousLiveDeploymentId && previousLiveDeploymentId !== deploymentId) { + if (debug) { + console.log(`[debug] Removing previous deployment: ${previousLiveDeploymentId}`); + } + + const deletePreviousResponse = await tinybirdFetch( + `${baseUrl}/v1/deployments/${previousLiveDeploymentId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${config.token}`, + }, + } + ); + + if (!deletePreviousResponse.ok) { + const deletePreviousBody = await deletePreviousResponse.text(); + return { + success: false, + result: "failed", + error: `Failed to remove previous deployment: ${deletePreviousResponse.status} ${deletePreviousResponse.statusText}\n${deletePreviousBody}`, + datasourceCount: resources.datasources.length, + pipeCount: resources.pipes.length, + connectionCount: resources.connections?.length ?? 0, + buildId: deploymentId, + }; + } + } + options?.callbacks?.onDeploymentLive?.(deploymentId); return { From 8610414217f7aae8db2e6a44e50cececbdf5e6a6 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Tue, 9 Jun 2026 13:14:22 +0200 Subject: [PATCH 2/3] Fix deployment cleanup e2e assertion --- e2e-live/deploy.test.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/e2e-live/deploy.test.ts b/e2e-live/deploy.test.ts index 7196755..3a23b56 100644 --- a/e2e-live/deploy.test.ts +++ b/e2e-live/deploy.test.ts @@ -196,6 +196,20 @@ async function waitForDeploymentStatus( ); } +async function getCurrentLiveDeployment( + config: LiveE2EConfig, + workspaceToken: string +): Promise { + const deployments = await listDeployments(config, workspaceToken); + const liveDeployment = deployments.find((deployment) => deployment.live); + + if (!liveDeployment) { + throw new Error("Expected a live deployment, but none was found."); + } + + return liveDeployment; +} + describeLive("E2E Live: deploy", () => { const config = liveConfig as LiveE2EConfig; @@ -292,8 +306,7 @@ describeLive("E2E Live: deploy", () => { const firstDeployResult = await runDeploy({ cwd: tempDir }); expect(firstDeployResult.success).toBe(true); expect(firstDeployResult.deploy?.success).toBe(true); - const firstDeploymentId = firstDeployResult.deploy?.buildId; - expect(firstDeploymentId).toBeTruthy(); + const previousLiveDeployment = await getCurrentLiveDeployment(config, workspaceToken); const tinybird = await importTinybirdClient(tempDir); const runId = `prod_deploy_second_${Date.now()}`; @@ -320,12 +333,12 @@ describeLive("E2E Live: deploy", () => { expect(secondDeployResult.deploy?.success).toBe(true); expect(secondDeployResult.deploy?.result).toBe("success"); expect(secondDeployResult.deploy?.buildId).toBeTruthy(); - expect(secondDeployResult.deploy?.buildId).not.toBe(firstDeploymentId); + expect(secondDeployResult.deploy?.buildId).not.toBe(previousLiveDeployment.id); const previousDeployment = await waitForDeploymentStatus( config, workspaceToken, - firstDeploymentId!, + previousLiveDeployment.id, "deleted" ); expect(previousDeployment.live).not.toBe(true); From 854d76ade63b3a83a4b8800548878c5636b1fbcc Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Tue, 9 Jun 2026 13:37:30 +0200 Subject: [PATCH 3/3] Bump package version to 0.0.77 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9d623a8..57a499f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tinybirdco/sdk", - "version": "0.0.76", + "version": "0.0.77", "description": "TypeScript SDK for Tinybird Forward - define datasources and pipes as TypeScript", "type": "module", "main": "./dist/index.js",