Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/local-bundle-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

Add an experimental `--local-bundle` deploy flag: your project is installed and bundled locally (like classic deploys) and only the build output is uploaded, while the image is still built remotely. Useful when the remote build's install step doesn't work for your project setup.
3 changes: 3 additions & 0 deletions apps/webapp/app/routes/api.v1.artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export async function action({ request }: ActionFunctionArgs) {
case "deployment_context":
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
break;
case "deployment_bundle":
errorMessage = `Bundle size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Reach out to us if you are seeing this error consistently.`;
break;
default:
body.data.type satisfies never;
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type GetDeploymentBuildEnvVarsResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { decryptSecret, EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
import { FINAL_DEPLOYMENT_STATUSES } from "~/v3/services/failDeployment.server";

const ParamsSchema = z.object({
deploymentId: z.string(),
});

// Returns the decrypted build-time env vars stored on a fromBundle deployment.
// Deliberately separate from the main GET deployment endpoint: this is secret
// material, and a dedicated route keeps access explicit and auditable. The vars
// are cleared when the deployment reaches a terminal status, so this only ever
// serves the active build window.
export async function loader({ request, params }: LoaderFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

try {
// 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 { deploymentId } = parsedParams.data;

const deployment = await prisma.workerDeployment.findFirst({
where: {
friendlyId: deploymentId,
environmentId: authenticatedEnv.id,
},
select: {
id: true,
status: true,
buildEnvVars: true,
},
});

if (!deployment) {
return json({ error: "Deployment not found" }, { status: 404 });
}

logger.info("Build env vars read", {
deploymentId,
environmentId: authenticatedEnv.id,
projectId: authenticatedEnv.projectId,
status: deployment.status,
hasVars: deployment.buildEnvVars !== null,
});

// Terminal deployments have their vars cleared; even if a clear is still in
// flight, never serve secrets for a build that is no longer active.
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
status: 200,
});
}

if (!deployment.buildEnvVars) {
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
status: 200,
});
}

// Vars exist but can't be read: fail LOUD. Returning an empty record here would
// be indistinguishable from "there were none" and let the build run without its
// build-time secrets (confusing failure at best, silently-wrong image at worst).
// Concrete trigger: ENCRYPTION_KEY rotation during the build window.
const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars);

if (!envelope.success) {
logger.error("Stored build env vars are not a valid encrypted envelope", {
deploymentId,
environmentId: authenticatedEnv.id,
});
return json(
{ error: "The stored build environment variables could not be read. Retry the deploy." },
{ status: 500 }
);
}

let variables: Record<string, string>;

try {
const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data);
variables = z.record(z.string()).parse(JSON.parse(decrypted));
} catch (error) {
logger.error("Failed to decrypt stored build env vars", {
deploymentId,
environmentId: authenticatedEnv.id,
error,
});
return json(
{
error: "The stored build environment variables could not be decrypted. Retry the deploy.",
},
{ status: 500 }
);
}

return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to load deployment build env vars", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
7 changes: 6 additions & 1 deletion apps/webapp/app/routes/api.v1.deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new InitializeDeploymentService();

try {
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
const { deployment, imageRef, eventStream, buildEnvVarsStored } = await service.call(
authenticatedEnv,
body.data
);

const responseBody: InitializeDeploymentResponseBody = {
id: deployment.friendlyId,
Expand All @@ -49,6 +52,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
imageTag: imageRef,
imagePlatform: deployment.imagePlatform,
eventStream,
// Only ack when we actually stored vars; older CLIs ignore this field.
...(buildEnvVarsStored ? { buildEnvVarsStored: true } : {}),
};

return json(responseBody, { status: 200 });
Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,7 @@ export async function enqueueBuild(
options: {
skipPromotion?: boolean;
configFilePath?: string;
fromBundle?: boolean;
}
) {
if (!client) return undefined;
Expand Down Expand Up @@ -1136,6 +1137,13 @@ export function isCloud(): boolean {
return true;
}

// PR preview environments are cloud-style installs running against the
// cloud's staging services. Without this, anything gated on the billing
// client silently no-ops there (e.g. remote builds never get enqueued).
if (env.LOGIN_ORIGIN.endsWith(".triggerlabs.dev")) {
return true;
}

if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") {
return true;
}
Expand Down
7 changes: 6 additions & 1 deletion apps/webapp/app/v3/services/artifacts.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,21 @@ const objectStoreClient =

const artifactKeyPrefixByType = {
deployment_context: "deployments",
// Distinct prefix on purpose: the artifact key is the one signal that survives
// any schema skew, so the build server can recognize a bundle even if the
// fromBundle flag gets stripped somewhere along the enqueue chain.
deployment_bundle: "bundles",
} as const;
const artifactBytesSizeLimitByType = {
deployment_context: 100 * 1024 * 1024, // 100MB
deployment_bundle: 100 * 1024 * 1024, // 100MB
} as const;

export class ArtifactsService extends BaseService {
private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET;

public createArtifact(
type: "deployment_context",
type: "deployment_context" | "deployment_bundle",
authenticatedEnv: AuthenticatedEnvironment,
contentLength?: number
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import { logger, tryCatch } from "@trigger.dev/core/v3";
import type {
BackgroundWorker,
PrismaClientOrTransaction,
WorkerDeployment,
import {
Prisma,
type BackgroundWorker,
type PrismaClientOrTransaction,
type WorkerDeployment,
} from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { type TaskMetadataCache } from "~/services/taskMetadataCache.server";
Expand Down Expand Up @@ -288,6 +289,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
name: error.name,
message: error.message,
},
// Build env vars only live for the active build window
buildEnvVars: Prisma.DbNull,
},
});

Expand Down
5 changes: 4 additions & 1 deletion apps/webapp/app/v3/services/deployment.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService } from "./baseService.server";
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
import { type WorkerDeployment, type Project } from "@trigger.dev/database";
import { Prisma, type WorkerDeployment, type Project } from "@trigger.dev/database";
import {
BuildServerMetadata,
logger,
Expand Down Expand Up @@ -227,6 +227,8 @@ export class DeploymentService extends BaseService {
status: "CANCELED",
canceledAt: new Date(),
canceledReason: data?.canceledReason,
// Build env vars only live for the active build window
buildEnvVars: Prisma.DbNull,
},
}),
(error) => ({
Expand Down Expand Up @@ -339,6 +341,7 @@ export class DeploymentService extends BaseService {
options: {
skipPromotion?: boolean;
configFilePath?: string;
fromBundle?: boolean;
}
) {
return fromPromise(
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/failDeployment.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { type WorkerDeploymentStatus } from "@trigger.dev/database";
import { Prisma, type WorkerDeploymentStatus } from "@trigger.dev/database";
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { DeploymentService } from "./deployment.server";
Expand Down Expand Up @@ -49,6 +49,8 @@ export class FailDeploymentService extends BaseService {
status: "FAILED",
failedAt: new Date(),
errorData: params.error,
// Build env vars only live for the active build window
buildEnvVars: Prisma.DbNull,
},
});

Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/v3/services/finalizeDeployment.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
import { Prisma } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { updateEnvConcurrencyLimits } from "../runQueue.server";
Expand Down Expand Up @@ -75,6 +76,8 @@ export class FinalizeDeploymentService extends BaseService {
deployedAt: new Date(),
// Only add the digest, if any
imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined,
// Build env vars only live for the active build window
buildEnvVars: Prisma.DbNull,
},
});

Expand Down
41 changes: 41 additions & 0 deletions apps/webapp/app/v3/services/initializeDeployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { customAlphabet } from "nanoid";
import { env } from "~/env.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { encryptSecret } from "~/services/secrets/secretStore.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server";
Expand All @@ -20,6 +21,11 @@ import { errAsync } from "neverthrow";

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

// Limits for fromBundle build env vars — they expand into --build-arg values, so
// keep them well under exec argv limits while staying generous for env vars.
const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024;
const BUILD_ENV_VARS_MAX_KEYS = 200;

export class InitializeDeploymentService extends BaseService {
public async call(
environment: AuthenticatedEnvironment,
Expand Down Expand Up @@ -56,6 +62,7 @@ export class InitializeDeploymentService extends BaseService {
return {
deployment: existingDeployment,
imageRef: existingDeployment.imageReference ?? "",
buildEnvVarsStored: false,
};
}

Expand Down Expand Up @@ -172,6 +179,36 @@ export class InitializeDeploymentService extends BaseService {
}
: undefined;

// Encrypt fromBundle build env vars for storage on the deployment row. Only
// meaningful for pre-bundled deploys; cleared on every terminal transition.
let encryptedBuildEnvVars: Awaited<ReturnType<typeof encryptSecret>> | undefined;

if (
payload.isNativeBuild &&
payload.fromBundle &&
payload.buildEnvVars &&
Object.keys(payload.buildEnvVars).length > 0
) {
const buildEnvVars = payload.buildEnvVars;

const keyCount = Object.keys(buildEnvVars).length;
if (keyCount > BUILD_ENV_VARS_MAX_KEYS) {
throw new ServiceValidationError(
`Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).`
);
}

const serialized = JSON.stringify(buildEnvVars);
const serializedBytes = Buffer.byteLength(serialized, "utf8");
if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) {
throw new ServiceValidationError(
`Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.`
);
}

encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized);
}

const buildServerMetadata: BuildServerMetadata | undefined =
payload.isNativeBuild || payload.buildId
? {
Expand All @@ -183,6 +220,7 @@ export class InitializeDeploymentService extends BaseService {
skipPromotion: payload.skipPromotion,
configFilePath: payload.configFilePath,
skipEnqueue: payload.skipEnqueue,
fromBundle: payload.fromBundle,
}
: {}),
}
Expand Down Expand Up @@ -247,6 +285,7 @@ export class InitializeDeploymentService extends BaseService {
projectId: environment.projectId,
externalBuildData,
buildServerMetadata,
buildEnvVars: encryptedBuildEnvVars,
triggeredById: triggeredBy?.id,
type: payload.type,
imageReference: imageRef,
Expand Down Expand Up @@ -276,6 +315,7 @@ export class InitializeDeploymentService extends BaseService {
.enqueueBuild(environment, deployment, payload.artifactKey, {
skipPromotion: payload.skipPromotion,
configFilePath: payload.configFilePath,
fromBundle: payload.fromBundle,
})
.orElse((error) => {
logger.error("Failed to enqueue build", {
Expand Down Expand Up @@ -310,6 +350,7 @@ export class InitializeDeploymentService extends BaseService {
deployment,
imageRef: deployment.imageReference ?? "",
eventStream,
buildEnvVarsStored: encryptedBuildEnvVars !== undefined,
};
});
}
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/v3/services/timeoutDeployment.server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Prisma } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { commonWorker } from "../commonWorker.server";
Expand Down Expand Up @@ -45,6 +46,8 @@ export class TimeoutDeploymentService extends BaseService {
status: "TIMED_OUT",
failedAt: new Date(),
errorData: { message: errorMessage, name: "TimeoutError" },
// Build env vars only live for the active build window
buildEnvVars: Prisma.DbNull,
},
});

Expand Down
Loading
Loading