From d2e02d84e3de837acf06b5f2ce98a88ed145e639 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 9 Feb 2025 19:40:39 +0000 Subject: [PATCH 1/5] WIP two-phase deployments --- .../webapp/app/components/primitives/Tabs.tsx | 4 +- .../runs/v3/RollbackDeploymentDialog.tsx | 46 ++++++- .../route.tsx | 43 ++++++- ....deployments.$deploymentVersion.promote.ts | 67 +++++++++++ ...eployments.$deploymentShortCode.promote.ts | 90 ++++++++++++++ ...ployments.$deploymentShortCode.rollback.ts | 6 +- .../authenticatedSocketConnection.server.ts | 4 +- .../changeCurrentDeployment.server.ts | 89 ++++++++++++++ .../v3/services/finalizeDeployment.server.ts | 26 +--- .../services/finalizeDeploymentV2.server.ts | 1 + .../v3/services/rollbackDeployment.server.ts | 51 -------- .../webapp/app/v3/utils/deploymentVersions.ts | 24 ++++ packages/cli-v3/src/apiClient.ts | 19 +++ packages/cli-v3/src/cli/index.ts | 2 + packages/cli-v3/src/commands/deploy.ts | 31 ++++- packages/cli-v3/src/commands/promote.ts | 112 ++++++++++++++++++ .../cli-v3/src/utilities/githubActions.ts | 27 +++++ packages/core/src/v3/index.ts | 1 + packages/core/src/v3/schemas/api.ts | 9 ++ packages/core/src/v3/types/tasks.ts | 21 +++- packages/trigger-sdk/src/v3/shared.ts | 5 + 21 files changed, 592 insertions(+), 86 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts create mode 100644 apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts create mode 100644 apps/webapp/app/v3/services/changeCurrentDeployment.server.ts delete mode 100644 apps/webapp/app/v3/services/rollbackDeployment.server.ts create mode 100644 apps/webapp/app/v3/utils/deploymentVersions.ts create mode 100644 packages/cli-v3/src/commands/promote.ts create mode 100644 packages/cli-v3/src/utilities/githubActions.ts diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index d3e5451484..2a61062cae 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -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 = { diff --git a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx index ccba03b030..76ae56e155 100644 --- a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx +++ b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx @@ -27,7 +27,7 @@ export function RollbackDeploymentDialog({ return ( - Roll back to this deployment? + Rollback to this deployment? 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"} + + + + + ); +} + +export function PromoteDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const isLoading = navigation.formAction === formAction; + + return ( + + Promote this deployment? + + This deployment will become the default for all future runs not explicitly tied to a + specific deployment. + + + + + +
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx index 227a3c6deb..c75cc2aba2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx @@ -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 ( @@ -234,6 +241,7 @@ export default function Page() { deployment={deployment} path={path} isSelected={isSelected} + currentDeployment={currentDeployment} /> ); @@ -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 ( {""} @@ -345,7 +360,7 @@ function DeploymentActionsCell({ isSelected={isSelected} popoverContent={ <> - {canRollback && ( + {canBeRolledBack && ( )} + {canBePromoted && ( + + + + + + + )} {canRetryIndexing && ( diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts new file mode 100644 index 0000000000..24f8e74a5e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts @@ -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 }); + } + } +} diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts new file mode 100644 index 0000000000..66c7715c93 --- /dev/null +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts @@ -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 rollbackSchema = 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: rollbackSchema }); + + 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 rollbackService = new ChangeCurrentDeploymentService(); + await rollbackService.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); + } + } +}; diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts index cd64736c52..5024f24f09 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts @@ -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, diff --git a/apps/webapp/app/v3/authenticatedSocketConnection.server.ts b/apps/webapp/app/v3/authenticatedSocketConnection.server.ts index 15cc16c72c..bf8658209c 100644 --- a/apps/webapp/app/v3/authenticatedSocketConnection.server.ts +++ b/apps/webapp/app/v3/authenticatedSocketConnection.server.ts @@ -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, { diff --git a/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts new file mode 100644 index 0000000000..9a28fc503a --- /dev/null +++ b/apps/webapp/app/v3/services/changeCurrentDeployment.server.ts @@ -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); + } +} diff --git a/apps/webapp/app/v3/services/finalizeDeployment.server.ts b/apps/webapp/app/v3/services/finalizeDeployment.server.ts index 042afa4a66..b25582dd44 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -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; diff --git a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts index b0091de6b0..2028ba423a 100644 --- a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts @@ -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; diff --git a/apps/webapp/app/v3/services/rollbackDeployment.server.ts b/apps/webapp/app/v3/services/rollbackDeployment.server.ts deleted file mode 100644 index 24f25e69cd..0000000000 --- a/apps/webapp/app/v3/services/rollbackDeployment.server.ts +++ /dev/null @@ -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, - }; - } -} diff --git a/apps/webapp/app/v3/utils/deploymentVersions.ts b/apps/webapp/app/v3/utils/deploymentVersions.ts new file mode 100644 index 0000000000..b460d9c068 --- /dev/null +++ b/apps/webapp/app/v3/utils/deploymentVersions.ts @@ -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; +} diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index e9064c641d..dedf3f639e 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -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"); diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index 3c31d030f2..562196191a 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -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); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 224e0996d1..c06f784f44 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -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 ", "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_WORKER_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( diff --git a/packages/cli-v3/src/commands/promote.ts b/packages/cli-v3/src/commands/promote.ts new file mode 100644 index 0000000000..65bfe8ee6b --- /dev/null +++ b/packages/cli-v3/src/commands/promote.ts @@ -0,0 +1,112 @@ +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; + +export function configurePromoteCommand(program: Command) { + return commonOptions( + program + .command("promote") + .description( + "Promote a previously deployed version to the current deployment:\n\n$ npx trigger.dev@latest promote [version]" + ) + .argument("[version]", "The version to promote") + .option("-c, --config ", "The name of the config file, found at [path]") + .option( + "-e, --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 ", + "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}`); +} diff --git a/packages/cli-v3/src/utilities/githubActions.ts b/packages/cli-v3/src/utilities/githubActions.ts new file mode 100644 index 0000000000..a4a80e75a1 --- /dev/null +++ b/packages/cli-v3/src/utilities/githubActions.ts @@ -0,0 +1,27 @@ +import { appendFileSync } from "node:fs"; + +export function setGithubActionsOutputAndEnvVars({ + envVars, + outputs, +}: { + envVars: Record; + outputs: Record; +}) { + // 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); + } +} diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 12fbc8b2d2..6509e90e92 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -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, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 3651c2a5f0..45d9fdb5b1 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -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; @@ -278,6 +279,14 @@ export const FailDeploymentResponseBody = z.object({ export type FailDeploymentResponseBody = z.infer; +export const PromoteDeploymentResponseBody = z.object({ + id: z.string(), + version: z.string(), + shortCode: z.string(), +}); + +export type PromoteDeploymentResponseBody = z.infer; + export const GetDeploymentResponseBody = z.object({ id: z.string(), status: z.enum([ diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index d238c26dfb..1ec3dd0409 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -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_WORKER_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; +export type TriggerAndWaitOptions = Omit< + TriggerOptions, + "idempotencyKey" | "idempotencyKeyTTL" | "version" +>; export type BatchTriggerOptions = { idempotencyKey?: IdempotencyKey | string | string[]; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 4c747f6240..96aee1d2af 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -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( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) @@ -950,6 +952,7 @@ export async function batchTriggerTasks( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) @@ -1205,6 +1208,7 @@ async function trigger_internal( metadata: options?.metadata, maxDuration: options?.maxDuration, machine: options?.machine, + lockToVersion: options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), }, }, { @@ -1265,6 +1269,7 @@ async function batchTrigger_internal( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) From 1734214f675f028ccf1b9e684ec8361bfec58c75 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 27 Feb 2025 16:05:16 +0000 Subject: [PATCH 2/5] Fix the help text --- packages/cli-v3/src/commands/promote.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli-v3/src/commands/promote.ts b/packages/cli-v3/src/commands/promote.ts index 65bfe8ee6b..bee97bac11 100644 --- a/packages/cli-v3/src/commands/promote.ts +++ b/packages/cli-v3/src/commands/promote.ts @@ -27,9 +27,7 @@ export function configurePromoteCommand(program: Command) { return commonOptions( program .command("promote") - .description( - "Promote a previously deployed version to the current deployment:\n\n$ npx trigger.dev@latest promote [version]" - ) + .description("Promote a previously deployed version to the current deployment") .argument("[version]", "The version to promote") .option("-c, --config ", "The name of the config file, found at [path]") .option( From f7d4bd1ca30301a53919a031a4b6f3328151830e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 27 Feb 2025 16:14:26 +0000 Subject: [PATCH 3/5] Rename TRIGGER_WORKER_VERSION to TRIGGER_VERSION --- packages/cli-v3/src/commands/deploy.ts | 2 +- packages/core/src/v3/types/tasks.ts | 2 +- packages/react-hooks/src/hooks/useTaskTrigger.ts | 1 + packages/trigger-sdk/src/v3/shared.ts | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index c06f784f44..35478581da 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -486,7 +486,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { setGithubActionsOutputAndEnvVars({ envVars: { TRIGGER_DEPLOYMENT_VERSION: version, - TRIGGER_WORKER_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/${ diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 1ec3dd0409..c23fc4a9a4 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -774,7 +774,7 @@ export type TriggerOptions = { /** * 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_WORKER_VERSION environment + * 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 diff --git a/packages/react-hooks/src/hooks/useTaskTrigger.ts b/packages/react-hooks/src/hooks/useTaskTrigger.ts index a65c274d4b..240660e045 100644 --- a/packages/react-hooks/src/hooks/useTaskTrigger.ts +++ b/packages/react-hooks/src/hooks/useTaskTrigger.ts @@ -85,6 +85,7 @@ export function useTaskTrigger( maxAttempts: options?.maxAttempts, metadata: options?.metadata, maxDuration: options?.maxDuration, + lockToVersion: options?.version, }, }); diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 96aee1d2af..a2ff0f35fc 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -615,7 +615,7 @@ export async function batchTriggerById( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) @@ -952,7 +952,7 @@ export async function batchTriggerTasks( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) @@ -1208,7 +1208,7 @@ async function trigger_internal( metadata: options?.metadata, maxDuration: options?.maxDuration, machine: options?.machine, - lockToVersion: options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), + lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"), }, }, { @@ -1269,7 +1269,7 @@ async function batchTrigger_internal( metadata: item.options?.metadata, maxDuration: item.options?.maxDuration, machine: item.options?.machine, - lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_WORKER_VERSION"), + lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"), }, } satisfies BatchTriggerTaskV2RequestBody["items"][0]; }) From 96d14d11542d0cd335467c64c1928eb6a0a6ba1b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 27 Feb 2025 17:14:12 +0000 Subject: [PATCH 4/5] Add changeset --- .changeset/cold-coins-burn.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cold-coins-burn.md diff --git a/.changeset/cold-coins-burn.md b/.changeset/cold-coins-burn.md new file mode 100644 index 0000000000..6de3d72ec3 --- /dev/null +++ b/.changeset/cold-coins-burn.md @@ -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 From ab5725f258c04f307c3fbde0e3cfb489fec03882 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 27 Feb 2025 17:15:46 +0000 Subject: [PATCH 5/5] A few naming fixes --- ...$projectId.deployments.$deploymentShortCode.promote.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts index 66c7715c93..c60ebd39ea 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts @@ -7,7 +7,7 @@ import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; -export const rollbackSchema = z.object({ +export const promoteSchema = z.object({ redirectUrl: z.string(), }); @@ -21,7 +21,7 @@ export const action: ActionFunction = async ({ request, params }) => { const { projectId, deploymentShortCode } = ParamSchema.parse(params); const formData = await request.formData(); - const submission = parse(formData, { schema: rollbackSchema }); + const submission = parse(formData, { schema: promoteSchema }); if (!submission.value) { return json(submission); @@ -62,8 +62,8 @@ export const action: ActionFunction = async ({ request, params }) => { ); } - const rollbackService = new ChangeCurrentDeploymentService(); - await rollbackService.call(deployment, "promote"); + const promoteService = new ChangeCurrentDeploymentService(); + await promoteService.call(deployment, "promote"); return redirectWithSuccessMessage( submission.value.redirectUrl,