Feat: two phase deployment, version pinning (#1739)
* WIP two-phase deployments * Fix the help text * Rename TRIGGER_WORKER_VERSION to TRIGGER_VERSION * Add changeset * A few naming fixes
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/react-hooks": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add support for two-phase deployments and task version pinning
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Link, NavLink, useLocation } from "@remix-run/react";
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { projectPubSub } from "~/v3/services/projectPubSub.server";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type TabsProps = {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RollbackDeploymentDialog({
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Roll back to this deployment?</DialogHeader>
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
@@ -50,7 +50,49 @@ export function RollbackDeploymentDialog({
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Roll back deployment"}
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
|
||||
+39
-4
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
ArrowUturnRightIcon,
|
||||
BookOpenIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
@@ -41,7 +42,10 @@ import {
|
||||
deploymentStatuses,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
|
||||
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import {
|
||||
PromoteDeploymentDialog,
|
||||
RollbackDeploymentDialog,
|
||||
} from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
@@ -58,6 +62,7 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -106,6 +111,8 @@ export default function Page() {
|
||||
|
||||
const { deploymentParam } = useParams();
|
||||
|
||||
const currentDeployment = deployments.find((d) => d.isCurrent);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -234,6 +241,7 @@ export default function Page() {
|
||||
deployment={deployment}
|
||||
path={path}
|
||||
isSelected={isSelected}
|
||||
currentDeployment={currentDeployment}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -320,18 +328,25 @@ function DeploymentActionsCell({
|
||||
deployment,
|
||||
path,
|
||||
isSelected,
|
||||
currentDeployment,
|
||||
}: {
|
||||
deployment: DeploymentListItem;
|
||||
path: string;
|
||||
isSelected: boolean;
|
||||
currentDeployment?: DeploymentListItem;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const project = useProject();
|
||||
|
||||
const canRollback = !deployment.isCurrent && deployment.isDeployed;
|
||||
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
|
||||
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
|
||||
const canBeRolledBack =
|
||||
canBeMadeCurrent &&
|
||||
currentDeployment?.version &&
|
||||
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
|
||||
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
|
||||
|
||||
if (!canRollback && !canRetryIndexing) {
|
||||
if (!canBeMadeCurrent && !canRetryIndexing) {
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
@@ -345,7 +360,7 @@ function DeploymentActionsCell({
|
||||
isSelected={isSelected}
|
||||
popoverContent={
|
||||
<>
|
||||
{canRollback && (
|
||||
{canBeRolledBack && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -365,6 +380,26 @@ function DeploymentActionsCell({
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canBePromoted && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowUturnRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Promote…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<PromoteDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canRetryIndexing && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
deploymentVersion: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const { deploymentVersion } = parsedParams.data;
|
||||
|
||||
const deployment = await prisma.workerDeployment.findFirst({
|
||||
where: {
|
||||
version: deploymentVersion,
|
||||
environmentId: authenticatedEnv.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
return json({ error: "Deployment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new ChangeCurrentDeploymentService();
|
||||
await service.call(deployment, "promote");
|
||||
|
||||
return json(
|
||||
{
|
||||
id: deployment.friendlyId,
|
||||
version: deployment.version,
|
||||
shortCode: deployment.shortCode,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
} else {
|
||||
return json({ error: "Failed to promote deployment" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
|
||||
|
||||
export const promoteSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
projectId: z.string(),
|
||||
deploymentShortCode: z.string(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectId, deploymentShortCode } = ParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: promoteSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
id: projectId,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
|
||||
}
|
||||
|
||||
const deployment = await prisma.workerDeployment.findUnique({
|
||||
where: {
|
||||
projectId_shortCode: {
|
||||
projectId: project.id,
|
||||
shortCode: deploymentShortCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.redirectUrl,
|
||||
request,
|
||||
"Deployment not found"
|
||||
);
|
||||
}
|
||||
|
||||
const promoteService = new ChangeCurrentDeploymentService();
|
||||
await promoteService.call(deployment, "promote");
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
submission.value.redirectUrl,
|
||||
request,
|
||||
`Promoted deployment version ${deployment.version} to current.`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to promote deployment", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
submission.error = { runParam: error.message };
|
||||
return json(submission);
|
||||
} else {
|
||||
logger.error("Failed to promote deployment", { error });
|
||||
submission.error = { runParam: JSON.stringify(error) };
|
||||
return json(submission);
|
||||
}
|
||||
}
|
||||
};
|
||||
+3
-3
@@ -5,7 +5,7 @@ import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { RollbackDeploymentService } from "~/v3/services/rollbackDeployment.server";
|
||||
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
|
||||
|
||||
export const rollbackSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
@@ -65,8 +65,8 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const rollbackService = new RollbackDeploymentService();
|
||||
await rollbackService.call(deployment);
|
||||
const rollbackService = new ChangeCurrentDeploymentService();
|
||||
await rollbackService.call(deployment, "rollback");
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
submission.value.redirectUrl,
|
||||
|
||||
@@ -43,7 +43,9 @@ export class AuthenticatedSocketConnection {
|
||||
});
|
||||
});
|
||||
},
|
||||
canSendMessage: () => ws.readyState === WebSocket.OPEN,
|
||||
canSendMessage() {
|
||||
return ws.readyState === WebSocket.OPEN;
|
||||
},
|
||||
});
|
||||
|
||||
this._consumer = new DevQueueConsumer(this.id, authenticatedEnv, this._sender, {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { WorkerDeployment } from "@trigger.dev/database";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
import { compareDeploymentVersions } from "../utils/deploymentVersions";
|
||||
|
||||
export type ChangeCurrentDeploymentDirection = "promote" | "rollback";
|
||||
|
||||
export class ChangeCurrentDeploymentService extends BaseService {
|
||||
public async call(deployment: WorkerDeployment, direction: ChangeCurrentDeploymentDirection) {
|
||||
if (!deployment.workerId) {
|
||||
throw new ServiceValidationError(
|
||||
direction === "promote"
|
||||
? "Deployment is not associated with a worker and cannot be promoted."
|
||||
: "Deployment is not associated with a worker and cannot be rolled back."
|
||||
);
|
||||
}
|
||||
|
||||
if (deployment.status !== "DEPLOYED") {
|
||||
throw new ServiceValidationError(
|
||||
direction === "promote"
|
||||
? "Deployment must be in the DEPLOYED state to be promoted."
|
||||
: "Deployment must be in the DEPLOYED state to be rolled back."
|
||||
);
|
||||
}
|
||||
|
||||
const currentPromotion = await this._prisma.workerDeploymentPromotion.findFirst({
|
||||
where: {
|
||||
environmentId: deployment.environmentId,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
select: {
|
||||
deployment: {
|
||||
select: { id: true, version: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (currentPromotion) {
|
||||
if (currentPromotion.deployment.id === deployment.id) {
|
||||
throw new ServiceValidationError("Deployment is already the current deployment.");
|
||||
}
|
||||
|
||||
// if there is a current promotion, we have to validate we are moving in the right direction based on the deployment versions
|
||||
switch (direction) {
|
||||
case "promote": {
|
||||
if (
|
||||
compareDeploymentVersions(currentPromotion.deployment.version, deployment.version) >= 0
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot promote a deployment that is older than the current deployment."
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rollback": {
|
||||
if (
|
||||
compareDeploymentVersions(currentPromotion.deployment.version, deployment.version) <= 0
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot rollback to a deployment that is newer than the current deployment."
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//set this deployment as the current deployment for this environment
|
||||
await this._prisma.workerDeploymentPromotion.upsert({
|
||||
where: {
|
||||
environmentId_label: {
|
||||
environmentId: deployment.environmentId,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
deploymentId: deployment.id,
|
||||
environmentId: deployment.environmentId,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
update: {
|
||||
deploymentId: deployment.id,
|
||||
},
|
||||
});
|
||||
|
||||
await ExecuteTasksWaitingForDeployService.enqueue(deployment.workerId, this._prisma);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
@@ -7,7 +6,7 @@ import { marqs } from "../marqs/index.server";
|
||||
import { registryProxy } from "../registryProxy.server";
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
import { ChangeCurrentDeploymentService } from "./changeCurrentDeployment.server";
|
||||
import { projectPubSub } from "./projectPubSub.server";
|
||||
|
||||
export class FinalizeDeploymentService extends BaseService {
|
||||
@@ -72,23 +71,11 @@ export class FinalizeDeploymentService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
//set this deployment as the current deployment for this environment
|
||||
await this._prisma.workerDeploymentPromotion.upsert({
|
||||
where: {
|
||||
environmentId_label: {
|
||||
environmentId: authenticatedEnv.id,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
deploymentId: finalizedDeployment.id,
|
||||
environmentId: authenticatedEnv.id,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
update: {
|
||||
deploymentId: finalizedDeployment.id,
|
||||
},
|
||||
});
|
||||
if (typeof body.skipPromotion === "undefined" || !body.skipPromotion) {
|
||||
const promotionService = new ChangeCurrentDeploymentService();
|
||||
|
||||
await promotionService.call(finalizedDeployment, "promote");
|
||||
}
|
||||
|
||||
try {
|
||||
//send a notification that a new worker has been created
|
||||
@@ -123,7 +110,6 @@ export class FinalizeDeploymentService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
await ExecuteTasksWaitingForDeployService.enqueue(deployment.worker.id, this._prisma);
|
||||
await PerformDeploymentAlertsService.enqueue(deployment.id);
|
||||
|
||||
return finalizedDeployment;
|
||||
|
||||
@@ -135,6 +135,7 @@ export class FinalizeDeploymentV2Service extends BaseService {
|
||||
const finalizedDeployment = await finalizeService.call(authenticatedEnv, id, {
|
||||
imageReference: fullImage,
|
||||
skipRegistryProxy: true,
|
||||
skipPromotion: body.skipPromotion,
|
||||
});
|
||||
|
||||
return finalizedDeployment;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { WorkerDeployment } from "@trigger.dev/database";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
|
||||
export class RollbackDeploymentService extends BaseService {
|
||||
public async call(deployment: WorkerDeployment) {
|
||||
if (deployment.status !== "DEPLOYED") {
|
||||
logger.error("Can't roll back to unsuccessful deployment", { id: deployment.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const promotion = await this._prisma.workerDeploymentPromotion.findFirst({
|
||||
where: {
|
||||
deploymentId: deployment.id,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
});
|
||||
|
||||
if (promotion) {
|
||||
logger.error(`Deployment is already the current deployment`, { id: deployment.id });
|
||||
return;
|
||||
}
|
||||
|
||||
await this._prisma.workerDeploymentPromotion.upsert({
|
||||
where: {
|
||||
environmentId_label: {
|
||||
environmentId: deployment.environmentId,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
deploymentId: deployment.id,
|
||||
environmentId: deployment.environmentId,
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
},
|
||||
update: {
|
||||
deploymentId: deployment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (deployment.workerId) {
|
||||
await ExecuteTasksWaitingForDeployService.enqueue(deployment.workerId, this._prisma);
|
||||
}
|
||||
|
||||
return {
|
||||
id: deployment.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Compares two versions of a deployment, like 20250208.1 and 20250208.2
|
||||
// Returns -1 if versionA is older than versionB, 0 if they are the same, and 1 if versionA is newer than versionB
|
||||
export function compareDeploymentVersions(versionA: string, versionB: string) {
|
||||
const [dateA, numberA] = versionA.split(".");
|
||||
const [dateB, numberB] = versionB.split(".");
|
||||
|
||||
if (dateA < dateB) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (dateA > dateB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (numberA < numberB) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (numberA > numberB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FailDeploymentRequestBody,
|
||||
FailDeploymentResponseBody,
|
||||
FinalizeDeploymentRequestBody,
|
||||
PromoteDeploymentResponseBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { zodfetch, ApiError, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch";
|
||||
|
||||
@@ -315,6 +316,24 @@ export class CliApiClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
async promoteDeployment(version: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("promoteDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
PromoteDeploymentResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${version}/promote`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async startDeploymentIndexing(deploymentId: string, body: StartDeploymentIndexingRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("startDeploymentIndexing: No access token");
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureUpdateCommand } from "../commands/update.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { configureDeployCommand } from "../commands/deploy.js";
|
||||
import { installExitHandler } from "./common.js";
|
||||
import { configurePromoteCommand } from "../commands/promote.js";
|
||||
|
||||
export const program = new Command();
|
||||
|
||||
@@ -22,6 +23,7 @@ configureLoginCommand(program);
|
||||
configureInitCommand(program);
|
||||
configureDevCommand(program);
|
||||
configureDeployCommand(program);
|
||||
configurePromoteCommand(program);
|
||||
configureWhoamiCommand(program);
|
||||
configureLogoutCommand(program);
|
||||
configureListProfilesCommand(program);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { getTmpDir } from "../utilities/tempDirectories.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { login } from "./login.js";
|
||||
import { updateTriggerPackages } from "./update.js";
|
||||
import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js";
|
||||
|
||||
const DeployCommandOptions = CommonCommandOptions.extend({
|
||||
dryRun: z.boolean().default(false),
|
||||
@@ -49,6 +50,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({
|
||||
apiUrl: z.string().optional(),
|
||||
saveLogs: z.boolean().default(false),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
skipPromotion: z.boolean().default(false),
|
||||
noCache: z.boolean().default(false),
|
||||
envFile: z.string().optional(),
|
||||
network: z.enum(["default", "none", "host"]).optional(),
|
||||
@@ -87,6 +89,10 @@ export function configureDeployCommand(program: Command) {
|
||||
"--env-file <env file>",
|
||||
"Path to the .env file to load into the CLI process. Defaults to .env in the project directory."
|
||||
)
|
||||
.option(
|
||||
"--skip-promotion",
|
||||
"Skip promoting the deployment to the current deployment for the environment."
|
||||
)
|
||||
)
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
@@ -157,7 +163,7 @@ export async function deployCommand(dir: string, options: unknown) {
|
||||
}
|
||||
|
||||
async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
intro("Deploying project");
|
||||
intro(`Deploying project${options.skipPromotion ? " (without promotion)" : ""}`);
|
||||
|
||||
if (!options.skipUpdateCheck) {
|
||||
await updateTriggerPackages(dir, { ...options }, true, true);
|
||||
@@ -444,6 +450,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
{
|
||||
imageReference,
|
||||
selfHosted: options.selfHosted,
|
||||
skipPromotion: options.skipPromotion,
|
||||
},
|
||||
(logMessage) => {
|
||||
if (isLinksSupported) {
|
||||
@@ -475,6 +482,28 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
isLinksSupported ? `| ${deploymentLink} | ${testLink}` : ""
|
||||
}`
|
||||
);
|
||||
|
||||
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 ? "false" : "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncEnvVarsWithServer(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { intro, outro } from "@clack/prompts";
|
||||
import { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { getProjectClient } from "../utilities/session.js";
|
||||
import { login } from "./login.js";
|
||||
|
||||
const PromoteCommandOptions = CommonCommandOptions.extend({
|
||||
projectRef: z.string().optional(),
|
||||
apiUrl: z.string().optional(),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
config: z.string().optional(),
|
||||
env: z.enum(["prod", "staging"]),
|
||||
});
|
||||
|
||||
type PromoteCommandOptions = z.infer<typeof PromoteCommandOptions>;
|
||||
|
||||
export function configurePromoteCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("promote")
|
||||
.description("Promote a previously deployed version to the current deployment")
|
||||
.argument("[version]", "The version to promote")
|
||||
.option("-c, --config <config file>", "The name of the config file, found at [path]")
|
||||
.option(
|
||||
"-e, --env <env>",
|
||||
"Deploy to a specific environment (currently only prod and staging are supported)",
|
||||
"prod"
|
||||
)
|
||||
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file. This will override the project specified in the config file."
|
||||
)
|
||||
).action(async (version, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printStandloneInitialBanner(true);
|
||||
await promoteCommand(version, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function promoteCommand(version: string, options: unknown) {
|
||||
return await wrapCommandAction("promoteCommand", PromoteCommandOptions, options, async (opts) => {
|
||||
return await _promoteCommand(version, opts);
|
||||
});
|
||||
}
|
||||
|
||||
async function _promoteCommand(version: string, options: PromoteCommandOptions) {
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
"You must provide a version to promote like so: `npx trigger.dev@latest promote 20250208.1`"
|
||||
);
|
||||
}
|
||||
|
||||
intro(`Promoting version ${version}`);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConfig = await loadConfig({
|
||||
overrides: { project: options.projectRef },
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Resolved config", resolvedConfig);
|
||||
|
||||
const projectClient = await getProjectClient({
|
||||
accessToken: authorization.auth.accessToken,
|
||||
apiUrl: authorization.auth.apiUrl,
|
||||
projectRef: resolvedConfig.project,
|
||||
env: options.env,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!projectClient) {
|
||||
throw new Error("Failed to get project client");
|
||||
}
|
||||
|
||||
const promotion = await projectClient.client.promoteDeployment(version);
|
||||
|
||||
if (!promotion.success) {
|
||||
throw new Error(promotion.error);
|
||||
}
|
||||
|
||||
outro(`Promoted version ${version}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
export function setGithubActionsOutputAndEnvVars({
|
||||
envVars,
|
||||
outputs,
|
||||
}: {
|
||||
envVars: Record<string, string>;
|
||||
outputs: Record<string, string>;
|
||||
}) {
|
||||
// Set environment variables
|
||||
if (process.env.GITHUB_ENV) {
|
||||
const contents = Object.entries(envVars)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
|
||||
appendFileSync(process.env.GITHUB_ENV, contents);
|
||||
}
|
||||
|
||||
// Set outputs
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
const contents = Object.entries(outputs)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, contents);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export * from "./types/index.js";
|
||||
export { links } from "./links.js";
|
||||
export * from "./jwt.js";
|
||||
export * from "./idempotencyKeys.js";
|
||||
export * from "./utils/getEnv.js";
|
||||
export {
|
||||
formatDuration,
|
||||
formatDurationInDays,
|
||||
|
||||
@@ -223,6 +223,7 @@ export const FinalizeDeploymentRequestBody = z.object({
|
||||
imageReference: z.string(),
|
||||
selfHosted: z.boolean().optional(),
|
||||
skipRegistryProxy: z.boolean().optional(),
|
||||
skipPromotion: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type FinalizeDeploymentRequestBody = z.infer<typeof FinalizeDeploymentRequestBody>;
|
||||
@@ -278,6 +279,14 @@ export const FailDeploymentResponseBody = z.object({
|
||||
|
||||
export type FailDeploymentResponseBody = z.infer<typeof FailDeploymentResponseBody>;
|
||||
|
||||
export const PromoteDeploymentResponseBody = z.object({
|
||||
id: z.string(),
|
||||
version: z.string(),
|
||||
shortCode: z.string(),
|
||||
});
|
||||
|
||||
export type PromoteDeploymentResponseBody = z.infer<typeof PromoteDeploymentResponseBody>;
|
||||
|
||||
export const GetDeploymentResponseBody = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum([
|
||||
|
||||
@@ -771,9 +771,28 @@ export type TriggerOptions = {
|
||||
* The machine preset to use for this run. This will override the task's machine preset and any defaults.
|
||||
*/
|
||||
machine?: MachinePresetName;
|
||||
|
||||
/**
|
||||
* Specify the version of the deployed task to run. By default the "current" version is used at the time of execution,
|
||||
* but you can specify a specific version to run here. You can also set the TRIGGER_VERSION environment
|
||||
* variables to run a specific version for all tasks.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* await myTask.trigger({ foo: "bar" }, { version: "20250208.1" });
|
||||
* ```
|
||||
*
|
||||
* Note that this option is only available for `trigger` and NOT `triggerAndWait` (and their batch counterparts). The "wait" versions will always be locked
|
||||
* to the same version as the parent task that is triggering the child tasks.
|
||||
*/
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type TriggerAndWaitOptions = Omit<TriggerOptions, "idempotencyKey" | "idempotencyKeyTTL">;
|
||||
export type TriggerAndWaitOptions = Omit<
|
||||
TriggerOptions,
|
||||
"idempotencyKey" | "idempotencyKeyTTL" | "version"
|
||||
>;
|
||||
|
||||
export type BatchTriggerOptions = {
|
||||
idempotencyKey?: IdempotencyKey | string | string[];
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useTaskTrigger<TTask extends AnyTask>(
|
||||
maxAttempts: options?.maxAttempts,
|
||||
metadata: options?.metadata,
|
||||
maxDuration: options?.maxDuration,
|
||||
lockToVersion: options?.version,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TaskRunExecutionResult,
|
||||
TaskRunPromise,
|
||||
TaskFromIdentifier,
|
||||
getEnvVar,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { PollOptions, runs } from "./runs.js";
|
||||
import { tracer } from "./tracer.js";
|
||||
@@ -614,6 +615,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
|
||||
metadata: item.options?.metadata,
|
||||
maxDuration: item.options?.maxDuration,
|
||||
machine: item.options?.machine,
|
||||
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
|
||||
},
|
||||
} satisfies BatchTriggerTaskV2RequestBody["items"][0];
|
||||
})
|
||||
@@ -950,6 +952,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
|
||||
metadata: item.options?.metadata,
|
||||
maxDuration: item.options?.maxDuration,
|
||||
machine: item.options?.machine,
|
||||
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
|
||||
},
|
||||
} satisfies BatchTriggerTaskV2RequestBody["items"][0];
|
||||
})
|
||||
@@ -1205,6 +1208,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
|
||||
metadata: options?.metadata,
|
||||
maxDuration: options?.maxDuration,
|
||||
machine: options?.machine,
|
||||
lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1265,6 +1269,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
|
||||
metadata: item.options?.metadata,
|
||||
maxDuration: item.options?.maxDuration,
|
||||
machine: item.options?.machine,
|
||||
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
|
||||
},
|
||||
} satisfies BatchTriggerTaskV2RequestBody["items"][0];
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user