Skip to content

Commit 2e065ce

Browse files
committed
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.
1 parent 74150e1 commit 2e065ce

9 files changed

Lines changed: 1166 additions & 50 deletions

File tree

.changeset/deploy-external-id.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
`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.

apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6565
externalBuildData:
6666
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
6767
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
68+
canceledReason: deployment.canceledReason,
6869
worker: deployment.worker
6970
? {
7071
id: deployment.worker.friendlyId,

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
4242
const service = new InitializeDeploymentService();
4343

4444
try {
45-
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
45+
const result = await service.call(authenticatedEnv, body.data);
46+
const { deployment, imageRef } = result;
4647

4748
const responseBody: InitializeDeploymentResponseBody = {
4849
id: deployment.friendlyId,
4950
contentHash: deployment.contentHash,
5051
shortCode: deployment.shortCode,
5152
version: deployment.version,
52-
externalBuildData:
53-
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
5453
imageTag: imageRef,
5554
imagePlatform: deployment.imagePlatform,
56-
eventStream,
55+
externalId: deployment.externalId ?? undefined,
56+
outcome: result.outcome,
57+
...(result.outcome === "created"
58+
? {
59+
externalBuildData: result.deployment
60+
.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
61+
eventStream: result.eventStream,
62+
canceledDeployments: result.canceledDeployments,
63+
}
64+
: { isPromoted: result.isPromoted }),
5765
};
5866

5967
return json(responseBody, { status: 200 });
6068
} catch (error) {
6169
if (error instanceof ServiceValidationError) {
62-
return json({ error: error.message }, { status: 400 });
70+
return json({ error: error.message }, { status: error.status ?? 400 });
6371
}
6472

6573
logger.error("Error initializing deployment", { error });

apps/webapp/app/v3/services/initializeDeployment.server.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,53 @@ import { tryCatch } from "@trigger.dev/core";
1616
import { getRegistryConfig } from "../registryConfig.server";
1717
import { DeploymentService } from "./deployment.server";
1818
import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server";
19+
import {
20+
cancelSupersededDeployments,
21+
type SupersededDeployment,
22+
} from "./initializeDeployment/cancelSupersededDeployments.server";
23+
import {
24+
resolveExternalIdReuse,
25+
type ExternalIdReuseDeployment,
26+
} from "./initializeDeployment/resolveExternalIdReuse.server";
27+
import { type WorkerDeployment } from "@trigger.dev/database";
1928
import { errAsync } from "neverthrow";
2029

2130
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
2231

32+
type DeploymentEventStream = {
33+
s2: {
34+
basin: string;
35+
stream: string;
36+
accessToken: string;
37+
};
38+
};
39+
40+
export type InitializeDeploymentResult =
41+
| {
42+
outcome: "created";
43+
deployment: WorkerDeployment;
44+
imageRef: string;
45+
eventStream?: DeploymentEventStream;
46+
canceledDeployments?: SupersededDeployment[];
47+
}
48+
| {
49+
outcome: "existing";
50+
deployment: ExternalIdReuseDeployment;
51+
imageRef: string;
52+
isPromoted: boolean;
53+
};
54+
2355
export class InitializeDeploymentService extends BaseService {
2456
public async call(
2557
environment: AuthenticatedEnvironment,
2658
payload: InitializeDeploymentRequestBody
27-
) {
28-
return this.traceWithEnv("call", environment, async () => {
59+
): Promise<InitializeDeploymentResult> {
60+
return this.traceWithEnv("call", environment, async (span) => {
61+
if (payload.externalId) {
62+
span.setAttribute("externalId", payload.externalId);
63+
}
64+
span.setAttribute("force", payload.force ?? false);
65+
2966
if (payload.gitMeta?.commitSha?.startsWith("deployment_")) {
3067
// When we introduced automatic deployments via the build server, we slightly changed the deployment flow
3168
// 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 {
3976
// build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
4077
// once we stop supporting 3.x CLI versions.
4178

79+
if (payload.externalId || payload.force) {
80+
throw new ServiceValidationError(
81+
"externalId and force are not supported when attaching to an existing deployment",
82+
400
83+
);
84+
}
85+
4286
const existingDeploymentId = payload.gitMeta.commitSha;
4387
const existingDeployment = await this._prisma.workerDeployment.findFirst({
4488
where: {
@@ -53,7 +97,10 @@ export class InitializeDeploymentService extends BaseService {
5397
);
5498
}
5599

100+
span.setAttribute("outcome", "created");
101+
56102
return {
103+
outcome: "created",
57104
deployment: existingDeployment,
58105
imageRef: existingDeployment.imageReference ?? "",
59106
};
@@ -103,6 +150,56 @@ export class InitializeDeploymentService extends BaseService {
103150
);
104151
}
105152

153+
const deploymentService = new DeploymentService();
154+
155+
const reuse = await resolveExternalIdReuse({
156+
prisma: this._prisma,
157+
environmentId: environment.id,
158+
externalId: payload.externalId,
159+
force: payload.force,
160+
});
161+
162+
if (reuse.action === "reject") {
163+
span.setAttribute("outcome", "rejected");
164+
165+
throw new ServiceValidationError(
166+
`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.`,
167+
409
168+
);
169+
}
170+
171+
if (reuse.action === "short-circuit") {
172+
span.setAttribute("outcome", "existing");
173+
174+
logger.debug("Reusing deployed external id, skipping build", {
175+
environmentId: environment.id,
176+
projectId: environment.projectId,
177+
externalId: payload.externalId,
178+
version: reuse.deployment.version,
179+
});
180+
181+
return {
182+
outcome: "existing",
183+
deployment: reuse.deployment,
184+
imageRef: reuse.deployment.imageReference ?? "",
185+
isPromoted: reuse.isPromoted,
186+
};
187+
}
188+
189+
span.setAttribute("outcome", "created");
190+
191+
const canceledDeployments =
192+
reuse.action === "cancel-then-build"
193+
? await cancelSupersededDeployments({
194+
deploymentService,
195+
environmentId: environment.id,
196+
externalId: reuse.externalId,
197+
deployments: reuse.deployments,
198+
})
199+
: [];
200+
201+
span.setAttribute("canceledDeploymentCount", canceledDeployments.length);
202+
106203
// For the `PENDING` initial status, defer the creation of the Depot build until the deployment is started to avoid token expiration issues.
107204
// 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.
108205
const generateExternalBuildToken =
@@ -140,7 +237,6 @@ export class InitializeDeploymentService extends BaseService {
140237
const initialStatus =
141238
payload.initialStatus ?? (payload.isNativeBuild ? "PENDING" : "BUILDING");
142239

143-
const deploymentService = new DeploymentService();
144240
const s2StreamOrFail = await deploymentService
145241
.createEventStream(environment.project, { shortCode: deploymentShortCode })
146242
.andThen(({ basin, stream }) =>
@@ -253,6 +349,7 @@ export class InitializeDeploymentService extends BaseService {
253349
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
254350
git: payload.gitMeta ?? undefined,
255351
commitSHA: payload.gitMeta?.commitSha ?? undefined,
352+
externalId: payload.externalId,
256353
runtime: payload.runtime ?? environment.project.defaultRuntime ?? undefined,
257354
triggeredVia: payload.triggeredVia ?? undefined,
258355
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
@@ -307,9 +404,11 @@ export class InitializeDeploymentService extends BaseService {
307404
}
308405

309406
return {
407+
outcome: "created",
310408
deployment,
311409
imageRef: deployment.imageReference ?? "",
312410
eventStream,
411+
canceledDeployments,
313412
};
314413
});
315414
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { logger } from "~/services/logger.server";
2+
import { ServiceValidationError } from "~/v3/services/baseService.server";
3+
import type { DeploymentService } from "../deployment.server";
4+
import type { ExternalIdReuseDeployment } from "./resolveExternalIdReuse.server";
5+
6+
export type SupersededDeployment = Pick<ExternalIdReuseDeployment, "version" | "shortCode">;
7+
8+
export function supersededByForceReason(externalId: string): string {
9+
return `Superseded by a new deploy with --force for external id "${externalId}"`;
10+
}
11+
12+
type CancelSupersededDeploymentsOptions = {
13+
deploymentService: DeploymentService;
14+
environmentId: string;
15+
externalId: string;
16+
deployments: ExternalIdReuseDeployment[];
17+
};
18+
19+
export async function cancelSupersededDeployments({
20+
deploymentService,
21+
environmentId,
22+
externalId,
23+
deployments,
24+
}: CancelSupersededDeploymentsOptions): Promise<SupersededDeployment[]> {
25+
const canceled: SupersededDeployment[] = [];
26+
27+
for (const deployment of deployments) {
28+
const result = await deploymentService.cancelDeployment(
29+
{ id: environmentId },
30+
deployment.friendlyId,
31+
{
32+
canceledReason: supersededByForceReason(externalId),
33+
}
34+
);
35+
36+
if (result.isOk()) {
37+
canceled.push({ version: deployment.version, shortCode: deployment.shortCode });
38+
continue;
39+
}
40+
41+
if (
42+
result.error.type === "deployment_cannot_be_cancelled" ||
43+
result.error.type === "deployment_not_found"
44+
) {
45+
logger.debug("Superseded deployment was already final", {
46+
externalId,
47+
version: deployment.version,
48+
reason: result.error.type,
49+
});
50+
continue;
51+
}
52+
53+
if (result.error.type === "failed_to_delete_deployment_timeout") {
54+
logger.warn("Failed to dequeue the timeout job for a superseded deployment", {
55+
externalId,
56+
version: deployment.version,
57+
error: result.error.cause,
58+
});
59+
canceled.push({ version: deployment.version, shortCode: deployment.shortCode });
60+
continue;
61+
}
62+
63+
throw new ServiceValidationError(
64+
`Failed to cancel the in-progress deployment ${deployment.version} holding external id "${externalId}". Nothing was built — try again.`,
65+
500
66+
);
67+
}
68+
69+
return canceled;
70+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
2+
import { type PrismaClientOrTransaction, type WorkerDeployment } from "@trigger.dev/database";
3+
import { compareDeploymentVersions } from "../../utils/deploymentVersions";
4+
import { FINAL_DEPLOYMENT_STATUSES } from "../failDeployment.server";
5+
6+
const MAX_CANDIDATES = 20;
7+
8+
export type ExternalIdReuseDeployment = Pick<
9+
WorkerDeployment,
10+
| "id"
11+
| "friendlyId"
12+
| "shortCode"
13+
| "version"
14+
| "status"
15+
| "contentHash"
16+
| "imageReference"
17+
| "imagePlatform"
18+
| "externalId"
19+
>;
20+
21+
type ExternalIdReuseCandidate = ExternalIdReuseDeployment & { promotions: { id: string }[] };
22+
23+
export type ResolveExternalIdReuseResult =
24+
| { action: "build" }
25+
| { action: "short-circuit"; deployment: ExternalIdReuseDeployment; isPromoted: boolean }
26+
| { action: "reject"; deployment: ExternalIdReuseDeployment }
27+
| {
28+
action: "cancel-then-build";
29+
externalId: string;
30+
deployments: ExternalIdReuseDeployment[];
31+
};
32+
33+
export type ResolveExternalIdReuseOptions = {
34+
prisma: PrismaClientOrTransaction;
35+
environmentId: string;
36+
externalId?: string;
37+
force?: boolean;
38+
};
39+
40+
export async function resolveExternalIdReuse({
41+
prisma,
42+
environmentId,
43+
externalId,
44+
force,
45+
}: ResolveExternalIdReuseOptions): Promise<ResolveExternalIdReuseResult> {
46+
if (!externalId) {
47+
return { action: "build" };
48+
}
49+
50+
const candidates = await prisma.workerDeployment.findMany({
51+
where: { environmentId, externalId },
52+
select: {
53+
id: true,
54+
friendlyId: true,
55+
shortCode: true,
56+
version: true,
57+
status: true,
58+
contentHash: true,
59+
imageReference: true,
60+
imagePlatform: true,
61+
externalId: true,
62+
promotions: {
63+
where: { label: CURRENT_DEPLOYMENT_LABEL },
64+
select: { id: true },
65+
},
66+
},
67+
orderBy: { id: "desc" },
68+
take: MAX_CANDIDATES,
69+
});
70+
71+
const inFlight = byVersionDesc(
72+
candidates.filter((deployment) => !FINAL_DEPLOYMENT_STATUSES.includes(deployment.status))
73+
);
74+
75+
if (force) {
76+
return inFlight.length
77+
? { action: "cancel-then-build", externalId, deployments: inFlight }
78+
: { action: "build" };
79+
}
80+
81+
if (inFlight.length) {
82+
return { action: "reject", deployment: inFlight[0]! };
83+
}
84+
85+
const deployed = byVersionDesc(
86+
candidates.filter((deployment) => deployment.status === "DEPLOYED")
87+
);
88+
89+
if (deployed.length) {
90+
const deployment = deployed[0]!;
91+
return { action: "short-circuit", deployment, isPromoted: deployment.promotions.length > 0 };
92+
}
93+
94+
return { action: "build" };
95+
}
96+
97+
function byVersionDesc(deployments: ExternalIdReuseCandidate[]): ExternalIdReuseCandidate[] {
98+
return [...deployments].sort((a, b) => compareDeploymentVersions(b.version, a.version));
99+
}

0 commit comments

Comments
 (0)