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:
Eric Allam
2025-02-27 20:44:23 +00:00
committed by GitHub
parent 0e5ec8bfbc
commit 26f9a1e478
23 changed files with 598 additions and 86 deletions
+7
View File
@@ -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>
@@ -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 });
}
}
}
@@ -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);
}
}
};
@@ -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;
}
+19
View File
@@ -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");
+2
View File
@@ -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);
+30 -1
View File
@@ -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(
+110
View File
@@ -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);
}
}
+1
View File
@@ -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,
+9
View File
@@ -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([
+20 -1
View File
@@ -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,
},
});
+5
View File
@@ -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];
})