From cb9cd498d18f3889a40f4827156530773e9f3fcb Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 17 Aug 2026 17:38:26 +0200 Subject: [PATCH] feat(deploy): --external-id and --force for deploy idempotency A deploy can carry an opaque external id (commit SHA, CI run id, release tag). Repeating an id that already deployed returns the existing version as a no-op instead of rebuilding; an id with a build in flight is rejected with 409 naming that version; a failed id rebuilds freely. --force is non-destructive to deployments that already succeeded - both persist and the higher version wins - but cancels a build still in flight, so one id never has two live builds racing to define it. Cancelling writes a terminal status and appends a finalized event, which aborts a build the platform drives; a build it does not drive keeps running but can never land, and the CLI says so. Ids are deliberately not unique - reuse is resolved in application code by highest version, never timestamps. The no-op path mints no build credentials and no event stream (TRI-12923). What that means for callers: a --force rebuild leaves two deployments holding one id, and runs triggered with it go to the higher version once the rebuild lands, so the takeover needs no separate promotion. Until a successful build exists for an id, runs triggered with it park and then expire rather than falling back to current - a failed build is therefore visible to the caller as expired runs, not as runs on the wrong release. --- .changeset/deploy-external-id.md | 5 + .../api.v1.deployments.$deploymentId.ts | 1 + apps/webapp/app/routes/api.v1.deployments.ts | 18 +- .../services/initializeDeployment.server.ts | 105 +++- .../cancelSupersededDeployments.server.ts | 70 +++ .../resolveExternalIdReuse.server.ts | 99 ++++ .../test/cancelSupersededDeployments.test.ts | 135 +++++ .../test/resolveExternalIdReuse.test.ts | 518 ++++++++++++++++++ packages/cli-v3/src/commands/deploy.ts | 265 +++++++-- 9 files changed, 1166 insertions(+), 50 deletions(-) create mode 100644 .changeset/deploy-external-id.md create mode 100644 apps/webapp/app/v3/services/initializeDeployment/cancelSupersededDeployments.server.ts create mode 100644 apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts create mode 100644 apps/webapp/test/cancelSupersededDeployments.test.ts create mode 100644 apps/webapp/test/resolveExternalIdReuse.test.ts diff --git a/.changeset/deploy-external-id.md b/.changeset/deploy-external-id.md new file mode 100644 index 00000000000..e6de4ada1fe --- /dev/null +++ b/.changeset/deploy-external-id.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +`trigger.dev deploy --external-id` tags a deployment with an id of your own — a commit SHA, a CI run id, a release tag — so runs triggered by that release of your app go to that deployment. Deploying an id that is already deployed builds nothing and reports the existing version instead of creating a duplicate; use `--force` to rebuild it. diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index 6b7accd0291..6af09bf81fa 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -65,6 +65,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { externalBuildData: deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"], errorData: deployment.errorData as GetDeploymentResponseBody["errorData"], + canceledReason: deployment.canceledReason, worker: deployment.worker ? { id: deployment.worker.friendlyId, diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 273259d87df..5be291bae27 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -42,24 +42,32 @@ export async function action({ request, params }: ActionFunctionArgs) { const service = new InitializeDeploymentService(); try { - const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data); + const result = await service.call(authenticatedEnv, body.data); + const { deployment, imageRef } = result; const responseBody: InitializeDeploymentResponseBody = { id: deployment.friendlyId, contentHash: deployment.contentHash, shortCode: deployment.shortCode, version: deployment.version, - externalBuildData: - deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], imageTag: imageRef, imagePlatform: deployment.imagePlatform, - eventStream, + externalId: deployment.externalId ?? undefined, + outcome: result.outcome, + ...(result.outcome === "created" + ? { + externalBuildData: result.deployment + .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], + eventStream: result.eventStream, + canceledDeployments: result.canceledDeployments, + } + : { isPromoted: result.isPromoted }), }; return json(responseBody, { status: 200 }); } catch (error) { if (error instanceof ServiceValidationError) { - return json({ error: error.message }, { status: 400 }); + return json({ error: error.message }, { status: error.status ?? 400 }); } logger.error("Error initializing deployment", { error }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index fb8ffd259e4..c5b01c6084b 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -16,16 +16,53 @@ import { tryCatch } from "@trigger.dev/core"; import { getRegistryConfig } from "../registryConfig.server"; import { DeploymentService } from "./deployment.server"; import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server"; +import { + cancelSupersededDeployments, + type SupersededDeployment, +} from "./initializeDeployment/cancelSupersededDeployments.server"; +import { + resolveExternalIdReuse, + type ExternalIdReuseDeployment, +} from "./initializeDeployment/resolveExternalIdReuse.server"; +import { type WorkerDeployment } from "@trigger.dev/database"; import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); +type DeploymentEventStream = { + s2: { + basin: string; + stream: string; + accessToken: string; + }; +}; + +export type InitializeDeploymentResult = + | { + outcome: "created"; + deployment: WorkerDeployment; + imageRef: string; + eventStream?: DeploymentEventStream; + canceledDeployments?: SupersededDeployment[]; + } + | { + outcome: "existing"; + deployment: ExternalIdReuseDeployment; + imageRef: string; + isPromoted: boolean; + }; + export class InitializeDeploymentService extends BaseService { public async call( environment: AuthenticatedEnvironment, payload: InitializeDeploymentRequestBody - ) { - return this.traceWithEnv("call", environment, async () => { + ): Promise { + return this.traceWithEnv("call", environment, async (span) => { + if (payload.externalId) { + span.setAttribute("externalId", payload.externalId); + } + span.setAttribute("force", payload.force ?? false); + if (payload.gitMeta?.commitSha?.startsWith("deployment_")) { // When we introduced automatic deployments via the build server, we slightly changed the deployment flow // mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status @@ -39,6 +76,13 @@ export class InitializeDeploymentService extends BaseService { // build server experience for users with older CLI versions. We'll eventually be able to remove this workaround // once we stop supporting 3.x CLI versions. + if (payload.externalId || payload.force) { + throw new ServiceValidationError( + "externalId and force are not supported when attaching to an existing deployment", + 400 + ); + } + const existingDeploymentId = payload.gitMeta.commitSha; const existingDeployment = await this._prisma.workerDeployment.findFirst({ where: { @@ -53,7 +97,10 @@ export class InitializeDeploymentService extends BaseService { ); } + span.setAttribute("outcome", "created"); + return { + outcome: "created", deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", }; @@ -103,6 +150,56 @@ export class InitializeDeploymentService extends BaseService { ); } + const deploymentService = new DeploymentService(); + + const reuse = await resolveExternalIdReuse({ + prisma: this._prisma, + environmentId: environment.id, + externalId: payload.externalId, + force: payload.force, + }); + + if (reuse.action === "reject") { + span.setAttribute("outcome", "rejected"); + + throw new ServiceValidationError( + `A deployment for external id "${payload.externalId}" is already in progress (version ${reuse.deployment.version}). Wait for it to finish, or deploy again with --force to cancel it and start a new one.`, + 409 + ); + } + + if (reuse.action === "short-circuit") { + span.setAttribute("outcome", "existing"); + + logger.debug("Reusing deployed external id, skipping build", { + environmentId: environment.id, + projectId: environment.projectId, + externalId: payload.externalId, + version: reuse.deployment.version, + }); + + return { + outcome: "existing", + deployment: reuse.deployment, + imageRef: reuse.deployment.imageReference ?? "", + isPromoted: reuse.isPromoted, + }; + } + + span.setAttribute("outcome", "created"); + + const canceledDeployments = + reuse.action === "cancel-then-build" + ? await cancelSupersededDeployments({ + deploymentService, + environmentId: environment.id, + externalId: reuse.externalId, + deployments: reuse.deployments, + }) + : []; + + span.setAttribute("canceledDeploymentCount", canceledDeployments.length); + // For the `PENDING` initial status, defer the creation of the Depot build until the deployment is started to avoid token expiration issues. // For local and native builds we don't need to generate the Depot tokens. We still need to create an empty object sadly due to a bug in older CLI versions. const generateExternalBuildToken = @@ -140,7 +237,6 @@ export class InitializeDeploymentService extends BaseService { const initialStatus = payload.initialStatus ?? (payload.isNativeBuild ? "PENDING" : "BUILDING"); - const deploymentService = new DeploymentService(); const s2StreamOrFail = await deploymentService .createEventStream(environment.project, { shortCode: deploymentShortCode }) .andThen(({ basin, stream }) => @@ -253,6 +349,7 @@ export class InitializeDeploymentService extends BaseService { imagePlatform: env.DEPLOY_IMAGE_PLATFORM, git: payload.gitMeta ?? undefined, commitSHA: payload.gitMeta?.commitSha ?? undefined, + externalId: payload.externalId, runtime: payload.runtime ?? environment.project.defaultRuntime ?? undefined, triggeredVia: payload.triggeredVia ?? undefined, startedAt: initialStatus === "BUILDING" ? new Date() : undefined, @@ -307,9 +404,11 @@ export class InitializeDeploymentService extends BaseService { } return { + outcome: "created", deployment, imageRef: deployment.imageReference ?? "", eventStream, + canceledDeployments, }; }); } diff --git a/apps/webapp/app/v3/services/initializeDeployment/cancelSupersededDeployments.server.ts b/apps/webapp/app/v3/services/initializeDeployment/cancelSupersededDeployments.server.ts new file mode 100644 index 00000000000..fe847b31fca --- /dev/null +++ b/apps/webapp/app/v3/services/initializeDeployment/cancelSupersededDeployments.server.ts @@ -0,0 +1,70 @@ +import { logger } from "~/services/logger.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import type { DeploymentService } from "../deployment.server"; +import type { ExternalIdReuseDeployment } from "./resolveExternalIdReuse.server"; + +export type SupersededDeployment = Pick; + +export function supersededByForceReason(externalId: string): string { + return `Superseded by a new deploy with --force for external id "${externalId}"`; +} + +type CancelSupersededDeploymentsOptions = { + deploymentService: DeploymentService; + environmentId: string; + externalId: string; + deployments: ExternalIdReuseDeployment[]; +}; + +export async function cancelSupersededDeployments({ + deploymentService, + environmentId, + externalId, + deployments, +}: CancelSupersededDeploymentsOptions): Promise { + const canceled: SupersededDeployment[] = []; + + for (const deployment of deployments) { + const result = await deploymentService.cancelDeployment( + { id: environmentId }, + deployment.friendlyId, + { + canceledReason: supersededByForceReason(externalId), + } + ); + + if (result.isOk()) { + canceled.push({ version: deployment.version, shortCode: deployment.shortCode }); + continue; + } + + if ( + result.error.type === "deployment_cannot_be_cancelled" || + result.error.type === "deployment_not_found" + ) { + logger.debug("Superseded deployment was already final", { + externalId, + version: deployment.version, + reason: result.error.type, + }); + continue; + } + + if (result.error.type === "failed_to_delete_deployment_timeout") { + logger.warn("Failed to dequeue the timeout job for a superseded deployment", { + externalId, + version: deployment.version, + error: result.error.cause, + }); + canceled.push({ version: deployment.version, shortCode: deployment.shortCode }); + continue; + } + + throw new ServiceValidationError( + `Failed to cancel the in-progress deployment ${deployment.version} holding external id "${externalId}". Nothing was built — try again.`, + 500 + ); + } + + return canceled; +} diff --git a/apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts b/apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts new file mode 100644 index 00000000000..2d2724a18cd --- /dev/null +++ b/apps/webapp/app/v3/services/initializeDeployment/resolveExternalIdReuse.server.ts @@ -0,0 +1,99 @@ +import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; +import { type PrismaClientOrTransaction, type WorkerDeployment } from "@trigger.dev/database"; +import { compareDeploymentVersions } from "../../utils/deploymentVersions"; +import { FINAL_DEPLOYMENT_STATUSES } from "../failDeployment.server"; + +const MAX_CANDIDATES = 20; + +export type ExternalIdReuseDeployment = Pick< + WorkerDeployment, + | "id" + | "friendlyId" + | "shortCode" + | "version" + | "status" + | "contentHash" + | "imageReference" + | "imagePlatform" + | "externalId" +>; + +type ExternalIdReuseCandidate = ExternalIdReuseDeployment & { promotions: { id: string }[] }; + +export type ResolveExternalIdReuseResult = + | { action: "build" } + | { action: "short-circuit"; deployment: ExternalIdReuseDeployment; isPromoted: boolean } + | { action: "reject"; deployment: ExternalIdReuseDeployment } + | { + action: "cancel-then-build"; + externalId: string; + deployments: ExternalIdReuseDeployment[]; + }; + +export type ResolveExternalIdReuseOptions = { + prisma: PrismaClientOrTransaction; + environmentId: string; + externalId?: string; + force?: boolean; +}; + +export async function resolveExternalIdReuse({ + prisma, + environmentId, + externalId, + force, +}: ResolveExternalIdReuseOptions): Promise { + if (!externalId) { + return { action: "build" }; + } + + const candidates = await prisma.workerDeployment.findMany({ + where: { environmentId, externalId }, + select: { + id: true, + friendlyId: true, + shortCode: true, + version: true, + status: true, + contentHash: true, + imageReference: true, + imagePlatform: true, + externalId: true, + promotions: { + where: { label: CURRENT_DEPLOYMENT_LABEL }, + select: { id: true }, + }, + }, + orderBy: { id: "desc" }, + take: MAX_CANDIDATES, + }); + + const inFlight = byVersionDesc( + candidates.filter((deployment) => !FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) + ); + + if (force) { + return inFlight.length + ? { action: "cancel-then-build", externalId, deployments: inFlight } + : { action: "build" }; + } + + if (inFlight.length) { + return { action: "reject", deployment: inFlight[0]! }; + } + + const deployed = byVersionDesc( + candidates.filter((deployment) => deployment.status === "DEPLOYED") + ); + + if (deployed.length) { + const deployment = deployed[0]!; + return { action: "short-circuit", deployment, isPromoted: deployment.promotions.length > 0 }; + } + + return { action: "build" }; +} + +function byVersionDesc(deployments: ExternalIdReuseCandidate[]): ExternalIdReuseCandidate[] { + return [...deployments].sort((a, b) => compareDeploymentVersions(b.version, a.version)); +} diff --git a/apps/webapp/test/cancelSupersededDeployments.test.ts b/apps/webapp/test/cancelSupersededDeployments.test.ts new file mode 100644 index 00000000000..bf16e153502 --- /dev/null +++ b/apps/webapp/test/cancelSupersededDeployments.test.ts @@ -0,0 +1,135 @@ +import { errAsync, okAsync } from "neverthrow"; +import { describe, expect, it } from "vitest"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { + cancelSupersededDeployments, + supersededByForceReason, +} from "~/v3/services/initializeDeployment/cancelSupersededDeployments.server"; +import type { ExternalIdReuseDeployment } from "~/v3/services/initializeDeployment/resolveExternalIdReuse.server"; + +type CancelOutcome = + | { ok: true } + | { type: "deployment_cannot_be_cancelled" } + | { type: "deployment_not_found" } + | { type: "failed_to_delete_deployment_timeout"; cause: unknown } + | { type: "other"; cause: unknown }; + +function deployment(version: string): ExternalIdReuseDeployment { + return { + id: `id_${version}`, + friendlyId: `deployment_${version}`, + shortCode: `short_${version}`, + version, + status: "BUILDING", + contentHash: `hash_${version}`, + imageReference: `registry.example/image:${version}`, + imagePlatform: "linux/amd64", + externalId: "abc123", + }; +} + +function stubDeploymentService(outcomes: CancelOutcome[]) { + const attempted: string[] = []; + const reasons: Array = []; + let call = 0; + + const deploymentService = { + cancelDeployment(_env: { id: string }, friendlyId: string, data?: { canceledReason?: string }) { + attempted.push(friendlyId); + reasons.push(data?.canceledReason); + + const outcome = outcomes[call++] ?? { ok: true as const }; + return "ok" in outcome ? okAsync(undefined) : errAsync(outcome); + }, + }; + + return { deploymentService, attempted, reasons }; +} + +function run( + outcomes: CancelOutcome[], + deployments = [deployment("20260101.1")], + externalId = "abc123" +) { + const { deploymentService, attempted, reasons } = stubDeploymentService(outcomes); + + return { + attempted, + reasons, + result: cancelSupersededDeployments({ + deploymentService: deploymentService as never, + environmentId: "env_1", + externalId, + deployments, + }), + }; +} + +describe("cancelSupersededDeployments", () => { + it("returns what it cancelled, naming the superseding deploy in the reason", async () => { + const { result, attempted, reasons } = run([{ ok: true }]); + + await expect(result).resolves.toEqual([ + { version: "20260101.1", shortCode: "short_20260101.1" }, + ]); + expect(attempted).toEqual(["deployment_20260101.1"]); + expect(reasons).toEqual([supersededByForceReason("abc123")]); + }); + + it("cancels in the order it was given", async () => { + const { result, attempted } = run( + [{ ok: true }, { ok: true }, { ok: true }], + [deployment("20260101.10"), deployment("20260101.9"), deployment("20260101.2")] + ); + + await expect(result).resolves.toHaveLength(3); + expect(attempted).toEqual([ + "deployment_20260101.10", + "deployment_20260101.9", + "deployment_20260101.2", + ]); + }); + + it("skips a deployment that went final before the cancel landed", async () => { + const { result } = run([{ type: "deployment_cannot_be_cancelled" }]); + + await expect(result).resolves.toEqual([]); + }); + + it("skips a deployment that no longer exists", async () => { + const { result } = run([{ type: "deployment_not_found" }]); + + await expect(result).resolves.toEqual([]); + }); + + it("counts a cancel whose timeout cleanup failed", async () => { + const { result } = run([{ type: "failed_to_delete_deployment_timeout", cause: new Error() }]); + + await expect(result).resolves.toEqual([ + { version: "20260101.1", shortCode: "short_20260101.1" }, + ]); + }); + + it("keeps going after a skip", async () => { + const { result, attempted } = run( + [{ type: "deployment_cannot_be_cancelled" }, { ok: true }], + [deployment("20260101.2"), deployment("20260101.1")] + ); + + await expect(result).resolves.toEqual([ + { version: "20260101.1", shortCode: "short_20260101.1" }, + ]); + expect(attempted).toHaveLength(2); + }); + + it("throws and stops on a genuine failure", async () => { + const { result, attempted } = run( + [{ type: "other", cause: new Error("connection lost") }, { ok: true }], + [deployment("20260101.2"), deployment("20260101.1")] + ); + + await expect(result).rejects.toBeInstanceOf(ServiceValidationError); + await expect(result).rejects.toMatchObject({ status: 500 }); + expect(attempted).toEqual(["deployment_20260101.2"]); + }); +}); diff --git a/apps/webapp/test/resolveExternalIdReuse.test.ts b/apps/webapp/test/resolveExternalIdReuse.test.ts new file mode 100644 index 00000000000..86d17a1acad --- /dev/null +++ b/apps/webapp/test/resolveExternalIdReuse.test.ts @@ -0,0 +1,518 @@ +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient, WorkerDeploymentStatus } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import { resolveExternalIdReuse } from "~/v3/services/initializeDeployment/resolveExternalIdReuse.server"; + +vi.setConfig({ testTimeout: 30_000 }); + +async function seedEnvironment(prisma: PrismaClient) { + const slug = `s${Math.random().toString(36).slice(2, 10)}`; + const organization = await prisma.organization.create({ + data: { title: slug, slug }, + }); + + const project = await prisma.project.create({ + data: { + name: slug, + slug, + organizationId: organization.id, + externalRef: slug, + }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug, + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: slug, + pkApiKey: slug, + shortcode: slug, + }, + }); + + return { organization, project, environment }; +} + +async function seedDeployment( + prisma: PrismaClient, + args: { + projectId: string; + environmentId: string; + version: string; + status: WorkerDeploymentStatus; + externalId?: string; + } +) { + const unique = Math.random().toString(36).slice(2, 10); + + return prisma.workerDeployment.create({ + data: { + friendlyId: `deployment_${unique}`, + shortCode: `short_${unique}`, + contentHash: `hash_${unique}`, + imageReference: `registry.example/image:${args.version}`, + projectId: args.projectId, + environmentId: args.environmentId, + version: args.version, + status: args.status, + externalId: args.externalId, + }, + }); +} + +const IN_FLIGHT_STATUSES: WorkerDeploymentStatus[] = [ + "PENDING", + "BUILDING", + "INSTALLING", + "DEPLOYING", +]; + +const FINAL_NON_DEPLOYED_STATUSES: WorkerDeploymentStatus[] = ["FAILED", "CANCELED", "TIMED_OUT"]; + +describe("resolveExternalIdReuse", () => { + postgresTest("builds when no external id is passed", async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status: "DEPLOYED", + externalId: "abc123", + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId: undefined, + force: false, + }); + + expect(result.action).toBe("build"); + }); + + postgresTest("builds when the external id has never been seen", async ({ prisma }) => { + const { environment } = await seedEnvironment(prisma); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId: "brand-new-id", + force: false, + }); + + expect(result.action).toBe("build"); + }); + + postgresTest( + "short-circuits to the highest deployed version when the id is already deployed", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + for (const version of ["20260101.1", "20260101.2", "20260101.3"]) { + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version, + status: "DEPLOYED", + externalId, + }); + } + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("short-circuit"); + if (result.action === "short-circuit") { + expect(result.deployment.version).toBe("20260101.3"); + } + } + ); + + for (const status of FINAL_NON_DEPLOYED_STATUSES) { + postgresTest(`builds when the id only holds a ${status} deployment`, async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status, + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("build"); + }); + } + + for (const status of IN_FLIGHT_STATUSES) { + postgresTest(`rejects when the id holds a ${status} deployment`, async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.7", + status, + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("reject"); + if (result.action === "reject") { + expect(result.deployment.version).toBe("20260101.7"); + expect(result.deployment.status).toBe(status); + } + }); + } + + postgresTest( + "rejects when the id holds both an in-flight build and an older deployed version", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status: "DEPLOYED", + externalId, + }); + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.2", + status: "BUILDING", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("reject"); + if (result.action === "reject") { + expect(result.deployment.version).toBe("20260101.2"); + } + } + ); + + postgresTest( + "rejects on an in-flight build even when a higher version is already deployed", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.2", + status: "DEPLOYED", + externalId, + }); + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status: "BUILDING", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("reject"); + if (result.action === "reject") { + expect(result.deployment.version).toBe("20260101.1"); + } + } + ); + + postgresTest("builds when force is passed over a deployed id", async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + const existing = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status: "DEPLOYED", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("build"); + + const stillThere = await prisma.workerDeployment.findFirst({ where: { id: existing.id } }); + expect(stillThere?.status).toBe("DEPLOYED"); + }); + + for (const status of IN_FLIGHT_STATUSES) { + postgresTest( + `force cancels a ${status} deployment rather than racing it`, + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + const inFlight = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status, + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("cancel-then-build"); + if (result.action !== "cancel-then-build") return; + + expect(result.externalId).toBe(externalId); + expect(result.deployments.map((deployment) => deployment.id)).toEqual([inFlight.id]); + } + ); + } + + for (const status of FINAL_NON_DEPLOYED_STATUSES) { + postgresTest(`force has nothing to cancel over a ${status} deployment`, async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status, + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("build"); + }); + } + + postgresTest( + "force cancels every in-flight deployment, highest version first", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + const middle = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.9", + status: "BUILDING", + externalId, + }); + + const highest = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.10", + status: "PENDING", + externalId, + }); + + const lowest = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.2", + status: "DEPLOYING", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("cancel-then-build"); + if (result.action !== "cancel-then-build") return; + + expect(result.deployments.map((deployment) => deployment.id)).toEqual([ + highest.id, + middle.id, + lowest.id, + ]); + } + ); + + postgresTest("force lists only the in-flight rows, never the final ones", async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.1", + status: "DEPLOYED", + externalId, + }); + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.2", + status: "FAILED", + externalId, + }); + + const building = await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.3", + status: "BUILDING", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("cancel-then-build"); + if (result.action !== "cancel-then-build") return; + + expect(result.deployments.map((deployment) => deployment.id)).toEqual([building.id]); + }); + + postgresTest("force never reaches into another environment", async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const other = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: other.project.id, + environmentId: other.environment.id, + version: "20260101.1", + status: "BUILDING", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: true, + }); + + expect(result.action).toBe("build"); + expect(project.id).not.toBe(other.project.id); + }); + + postgresTest( + "picks the highest version, not the newest row, when versions are seeded out of creation order", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.10", + status: "DEPLOYED", + externalId, + }); + await seedDeployment(prisma, { + projectId: project.id, + environmentId: environment.id, + version: "20260101.9", + status: "DEPLOYED", + externalId, + }); + + const result = await resolveExternalIdReuse({ + prisma, + environmentId: environment.id, + externalId, + force: false, + }); + + expect(result.action).toBe("short-circuit"); + if (result.action === "short-circuit") { + expect(result.deployment.version).toBe("20260101.10"); + } + } + ); + + postgresTest("isolates the same external id across environments", async ({ prisma }) => { + const first = await seedEnvironment(prisma); + const second = await seedEnvironment(prisma); + const externalId = "abc123"; + + await seedDeployment(prisma, { + projectId: first.project.id, + environmentId: first.environment.id, + version: "20260101.1", + status: "DEPLOYED", + externalId, + }); + + const sameEnvironment = await resolveExternalIdReuse({ + prisma, + environmentId: first.environment.id, + externalId, + force: false, + }); + expect(sameEnvironment.action).toBe("short-circuit"); + + const otherEnvironment = await resolveExternalIdReuse({ + prisma, + environmentId: second.environment.id, + externalId, + force: false, + }); + expect(otherEnvironment.action).toBe("build"); + }); +}); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index ae8cc3ee5a8..7afa06982ae 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1,5 +1,10 @@ import { intro, log, outro } from "@clack/prompts"; -import { getBranch, prepareDeploymentError, tryCatch } from "@trigger.dev/core/v3"; +import { + EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH, + getBranch, + prepareDeploymentError, + tryCatch, +} from "@trigger.dev/core/v3"; import type { InitializeDeploymentRequestBody, InitializeDeploymentResponseBody, @@ -73,6 +78,8 @@ const DeployCommandOptions = CommonCommandOptions.extend({ saveLogs: z.boolean().default(false), skipUpdateCheck: z.boolean().default(false), skipPromotion: z.boolean().default(false), + externalId: z.string().optional(), + force: z.boolean().default(false), cache: z.boolean().default(true), envFile: z.string().optional(), // Local build options @@ -133,6 +140,14 @@ export function configureDeployCommand(program: Command) { "--skip-promotion", "Skip promoting the deployment to the current deployment for the environment." ) + .option( + "--external-id ", + `An id of your choosing for this deploy, such as a commit SHA, CI run id or release tag (max ${EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH} characters). Deploying the same id again returns the existing version instead of building it twice.` + ) + .option( + "--force", + "Build again even if --external-id has already been deployed. Requires --external-id." + ) ) .addOption( new CommandOption( @@ -256,6 +271,26 @@ async function deployCommand(dir: string, options: unknown) { } async function _deployCommand(dir: string, options: DeployCommandOptions) { + if (options.externalId !== undefined) { + options.externalId = options.externalId.trim(); + + if (options.externalId.length === 0) { + throw new Error("--external-id must not be empty."); + } + + if (options.externalId.length > EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH) { + throw new Error( + `--external-id must be at most ${EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH} characters.` + ); + } + } + + if (options.force && !options.externalId) { + throw new Error( + "--force requires --external-id. Without an id there is no previous deployment for --force to build over." + ); + } + if (!options.plain) { intro(`Deploying project${options.skipPromotion ? " (without promotion)" : ""}`); } @@ -311,6 +346,16 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); logger.debug("gitMeta", gitMeta); + const isAttachingToExistingDeployment = + Boolean(envVars.TRIGGER_EXISTING_DEPLOYMENT_ID) || + Boolean(gitMeta?.commitSha?.startsWith("deployment_")); + + if (isAttachingToExistingDeployment && (options.externalId || options.force)) { + throw new Error( + "--external-id and --force are not supported when attaching to an existing deployment. Remove the flags, or start a new deployment instead." + ); + } + const branch = options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; @@ -437,10 +482,55 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { isLocalBuild: options.localBuild, isNativeBuild: false, triggeredVia: getTriggeredVia(), + externalId: options.externalId, + force: options.force, }, envVars.TRIGGER_EXISTING_DEPLOYMENT_ID ); + if (deployment.outcome === "existing") { + const { rawDeploymentLink, rawTestLink } = buildDeploymentLinks({ + dashboardUrl: authorization.dashboardUrl, + projectRef: resolvedConfig.project, + env: options.env, + shortCode: deployment.shortCode, + }); + + setDeploymentGithubActionsOutput({ + version: deployment.version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: !deployment.isPromoted, + }); + + warnAboutSkippedBuild(options.externalId, deployment.isPromoted); + + const message = `Version ${deployment.version} was already deployed for --external-id ${options.externalId} — nothing to build`; + + if (options.plain) { + console.log(message); + + if (process.env.TRIGGER_DEPLOYMENT_LINK_OUTPUT_DISABLED !== "1") { + console.log(`Deployment: ${rawDeploymentLink}`); + console.log(`Test: ${rawTestLink}`); + } + } else { + outro( + `${message} ${isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : ""}` + ); + + if (!isLinksSupported) { + console.log("View deployment"); + console.log(rawDeploymentLink); + } + } + + return; + } + + warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId); + // When `externalBuildData` is not present the deployment implicitly goes into the local build path // which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud // and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion. @@ -516,10 +606,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const version = deployment.version; - const rawDeploymentLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; + const { rawDeploymentLink, rawTestLink } = buildDeploymentLinks({ + dashboardUrl: authorization.dashboardUrl, + projectRef: resolvedConfig.project, + env: options.env, + shortCode: deployment.shortCode, + }); const deploymentLink = cliLink("View deployment", rawDeploymentLink); const testLink = cliLink("Test tasks", rawTestLink); @@ -755,26 +847,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { console.log(`Full build logs have been saved to ${logPath}`); } - setGithubActionsOutputAndEnvVars({ - envVars: { - TRIGGER_DEPLOYMENT_VERSION: version, - TRIGGER_VERSION: version, - TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, - TRIGGER_DEPLOYMENT_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, - }, - outputs: { - deploymentVersion: version, - workerVersion: version, - deploymentShortCode: deployment.shortCode, - deploymentUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - testUrl: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, - needsPromotion: options.skipPromotion ? "true" : "false", - }, + setDeploymentGithubActionsOutput({ + version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: options.skipPromotion, }); } @@ -892,7 +970,7 @@ async function failDeploy( break; } case "CANCELED": { - await doOutputLogs("Canceled"); + await doOutputLogs(serverDeployment.canceledReason ?? "Canceled"); exitCommand("Failed to deploy project"); @@ -979,6 +1057,7 @@ async function initializeOrAttachDeployment( return { ...existingDeploymentOrError.data, imageTag: imageReference, + outcome: "created" as const, }; } @@ -993,6 +1072,92 @@ async function initializeOrAttachDeployment( return newDeploymentOrError.data; } +function buildDeploymentLinks({ + dashboardUrl, + projectRef, + env, + shortCode, +}: { + dashboardUrl: string; + projectRef: string; + env: DeployCommandOptions["env"]; + shortCode: string; +}) { + return { + rawDeploymentLink: `${dashboardUrl}/projects/v3/${projectRef}/deployments/${shortCode}`, + rawTestLink: `${dashboardUrl}/projects/v3/${projectRef}/test?environment=${ + env === "prod" ? "prod" : "stg" + }`, + }; +} + +function warnAboutSkippedBuild(externalId: string | undefined, isPromoted: boolean | undefined) { + prettyWarning( + "Environment variables were not synced because nothing was built.", + `If your environment variables have changed, deploy again with --force --external-id ${externalId} to rebuild this id and sync them.` + ); + + if (isPromoted === false) { + prettyWarning( + "This version is not the current deployment.", + "Promote it from the dashboard, or deploy again with --force to build a new version." + ); + } +} + +function warnAboutCanceledDeployments( + canceledDeployments: Array<{ version: string }> | undefined, + externalId: string | undefined +) { + if (!canceledDeployments?.length || !externalId) { + return; + } + + const versions = canceledDeployments.map((deployment) => deployment.version); + + const header = + versions.length === 1 + ? `--force canceled version ${versions[0]}, which was still building for --external-id ${externalId}` + : `--force canceled ${versions.length} in-progress deployments for --external-id ${externalId}: ${versions.join(", ")}`; + + prettyWarning( + header, + "A canceled deployment can never be deployed. The build is signalled to stop, but a build running on another machine can keep going for a few minutes before it notices." + ); +} + +function setDeploymentGithubActionsOutput({ + version, + shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion, +}: { + version: string; + shortCode: string; + rawDeploymentLink: string; + rawTestLink: string; + needsPromotion: boolean; +}) { + setGithubActionsOutputAndEnvVars({ + envVars: { + TRIGGER_DEPLOYMENT_VERSION: version, + TRIGGER_VERSION: version, + TRIGGER_DEPLOYMENT_SHORT_CODE: shortCode, + TRIGGER_DEPLOYMENT_URL: rawDeploymentLink, + TRIGGER_TEST_URL: rawTestLink, + }, + outputs: { + deploymentVersion: version, + workerVersion: version, + deploymentShortCode: shortCode, + deploymentUrl: rawDeploymentLink, + testUrl: rawTestLink, + needsPromotion: needsPromotion ? "true" : "false", + }, + }); +} + function getTriggeredVia(): DeploymentTriggeredVia { // Check specific CI providers first (most specific to least specific) if (isGitHubActions()) { @@ -1133,6 +1298,8 @@ async function handleNativeBuildServerDeploy({ skipPromotion: options.skipPromotion, configFilePath, triggeredVia: getTriggeredVia(), + externalId: options.externalId, + force: options.force, }); if (!initializeDeploymentResult.success) { @@ -1148,28 +1315,42 @@ async function handleNativeBuildServerDeploy({ options.env === "prod" ? "prod" : "stg" }`; + if (deployment.outcome === "existing") { + $deploymentSpinner.stop(`Version ${deployment.version} was already deployed`); + + setDeploymentGithubActionsOutput({ + version: deployment.version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: !deployment.isPromoted, + }); + + warnAboutSkippedBuild(options.externalId, deployment.isPromoted); + + outro( + `Version ${deployment.version} was already deployed for --external-id ${options.externalId} — nothing to build ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : rawDeploymentLink + }` + ); + + return; + } + const exposedDeploymentLink = isLinksSupported ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) : chalk.bold(rawDeploymentLink); $deploymentSpinner.stop("Deployment initialized"); log.info(`View deployment: ${exposedDeploymentLink}`); - setGithubActionsOutputAndEnvVars({ - envVars: { - TRIGGER_DEPLOYMENT_VERSION: deployment.version, - TRIGGER_VERSION: deployment.version, - TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, - TRIGGER_DEPLOYMENT_URL: rawDeploymentLink, - TRIGGER_TEST_URL: rawTestLink, - }, - outputs: { - deploymentVersion: deployment.version, - workerVersion: deployment.version, - deploymentShortCode: deployment.shortCode, - deploymentUrl: rawDeploymentLink, - testUrl: rawTestLink, - needsPromotion: options.skipPromotion ? "true" : "false", - }, + warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId); + + setDeploymentGithubActionsOutput({ + version: deployment.version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: options.skipPromotion, }); if (options.detach) {