+
Discover new env vars
-
- Select which environments should automatically discover and create new environment
- variables from Vercel during builds.
-
+ {availableEnvSlugs.length > 1 && (
+ 0 &&
+ availableEnvSlugs.every(
+ (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
+ ) &&
+ availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
+ }
+ disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
+ onCheckedChange={(checked) => {
+ onDiscoverEnvVarsChange(
+ checked
+ ? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
+ : []
+ );
+ }}
+ />
+ )}
- {availableEnvSlugs.length > 1 && (
-
0 &&
- availableEnvSlugs.every(
- (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
- ) &&
- availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
- }
- disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
- onCheckedChange={(checked) => {
- onDiscoverEnvVarsChange(
- checked
- ? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
- : []
- );
- }}
- />
- )}
+
+ Select which environments should automatically discover and create new environment
+ variables from Vercel during builds.
+
{availableEnvSlugs.map((slug) => {
@@ -155,13 +155,7 @@ export function BuildSettingsFields({
{/* Atomic deployments */}
-
- Atomic deployments
-
- When enabled, production deployments wait for Vercel deployment to complete before
- promoting the Trigger.dev deployment.
-
-
+
Atomic deployments
+
+ When enabled, production deployments wait for Vercel deployment to complete before
+ promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
+ Production Domains" option in your Vercel project settings to perform staged
+ deployments.{" "}
+
+ Learn more
+
+ .
+
>
);
diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
index f3635dbd08..6c3e0e3b4d 100644
--- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
+++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx
@@ -679,7 +679,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
-
+ e.preventDefault()}>
@@ -800,6 +800,20 @@ export function VercelOnboardingModal({
))}
+
+
+ If you skip this step, the{" "}
+ TRIGGER_SECRET_KEY{" "}
+ will not be installed for the staging environment in Vercel. You can configure this later in
+ project settings.
+
+
+
+
+ Make sure the staging branch in your Vercel project's Git settings matches the staging branch
+ configured in your GitHub integration.
+
+
{
if (teamId) {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.teams.getTeam({ teamId }),
+ VercelSchemas.getTeam,
"Failed to fetch Vercel team",
- { teamId }
+ { teamId },
+ toVercelApiError
).map((response) => response.slug);
}
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.user.getAuthUser(),
+ VercelSchemas.getAuthUser,
"Failed to fetch Vercel user",
- {}
+ {},
+ toVercelApiError
).map((response) => response?.user.username ?? "unknown");
}
@@ -333,10 +342,11 @@ export class VercelIntegrationRepository {
): ResultAsync<{ isValid: boolean }, VercelApiError> {
return this.getVercelClient(integration)
.andThen((client) =>
- ResultAsync.fromPromise(
+ callVercelWithRecovery(
client.user.getAuthUser(),
- toVercelApiError
- )
+ VercelSchemas.getAuthUser,
+ { context: "validateVercelToken" }
+ ).mapErr(toVercelApiError)
)
.map(() => ({ isValid: true }))
.orElse((error) =>
@@ -420,13 +430,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null
): ResultAsync {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
idOrName: projectId,
...(teamId && { teamId }),
}),
+ VercelSchemas.getCustomEnvironments,
"Failed to fetch Vercel custom environments",
- { projectId, teamId }
+ { projectId, teamId },
+ toVercelApiError
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
}
@@ -435,13 +447,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null,
): ResultAsync {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
+ VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variables",
- { projectId, teamId }
+ { projectId, teamId },
+ toVercelApiError
).map((response) => {
// Warn if response is paginated (more data exists that we're not fetching)
if (
@@ -467,13 +481,15 @@ export class VercelIntegrationRepository {
/** If provided, only include keys that pass this filter */
shouldIncludeKey?: (key: string) => boolean
): ResultAsync {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
+ VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variable values",
- { projectId, teamId, target }
+ { projectId, teamId, target },
+ toVercelApiError
).andThen((response) => {
// Apply all filters BEFORE decryption to avoid unnecessary API calls
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
@@ -510,13 +526,14 @@ export class VercelIntegrationRepository {
// Encrypted vars: fetch decrypted value via individual endpoint
// (list endpoint's decrypt param is deprecated)
- const result = await ResultAsync.fromPromise(
+ const result = await callVercelWithRecovery(
client.projects.getProjectEnv({
idOrName: projectId,
id: env.id,
...(teamId && { teamId }),
}),
- (error) => error
+ VercelSchemas.getProjectEnv,
+ { context: "resolveEnvVarValue" }
);
if (result.isErr()) {
@@ -552,13 +569,15 @@ export class VercelIntegrationRepository {
isSecret: boolean;
target: string[];
}>, VercelApiError> {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
+ VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variables",
- { teamId, projectId }
+ { teamId, projectId },
+ toVercelApiError
).map((response) => {
const envVars = response.data || [];
return envVars
@@ -593,13 +612,15 @@ export class VercelIntegrationRepository {
}>,
VercelApiError
> {
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
+ VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variable values",
- { teamId, projectId }
+ { teamId, projectId },
+ toVercelApiError
).andThen((listResponse) => {
const envVars = listResponse.data || [];
if (envVars.length === 0) {
@@ -635,12 +656,13 @@ export class VercelIntegrationRepository {
}
// Try to get the decrypted value for this shared env var
- const getResult = await ResultAsync.fromPromise(
+ const getResult = await callVercelWithRecovery(
client.environment.getSharedEnvVar({
id: envId,
teamId,
}),
- (error) => error
+ VercelSchemas.getSharedEnvVar,
+ { context: "getSharedEnvVar" }
);
if (getResult.isOk()) {
@@ -655,47 +677,12 @@ export class VercelIntegrationRepository {
};
}
- // Workaround: Vercel SDK may throw ResponseValidationError even when the API response
- // is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
- const error = getResult.error;
- let errorValue: string | undefined;
- if (error && typeof error === "object" && "rawValue" in error) {
- const rawValue = (error as any).rawValue;
- if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
- errorValue = rawValue.value as string | undefined;
- }
- }
-
- const fallbackValue = errorValue || listValue;
-
- if (fallbackValue) {
- logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
- teamId,
- envId,
- envKey,
- error: error instanceof Error ? error.message : String(error),
- hasErrorRawValue: !!errorValue,
- hasListValue: !!listValue,
- valueLength: fallbackValue.length,
- });
- return {
- key: envKey,
- value: fallbackValue,
- target: normalizeTarget(env.target),
- type,
- isSecret,
- applyToAllCustomEnvironments: applyToAllCustomEnvs,
- };
- }
-
- logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
+ logger.warn("Failed to get decrypted value for shared env var", {
teamId,
projectId,
envId,
envKey,
- error: error instanceof Error ? error.message : String(error),
- errorStack: error instanceof Error ? error.stack : undefined,
- hasRawValue: error && typeof error === "object" && "rawValue" in error,
+ error: getResult.error instanceof Error ? getResult.error.message : String(getResult.error),
});
return null;
})
@@ -723,11 +710,18 @@ export class VercelIntegrationRepository {
let from: string | undefined;
do {
- const response = await client.projects.getProjects({
- ...(teamId && { teamId }),
- limit: "100",
- ...(from && { from }),
- });
+ const response = await callVercelWithRecovery(
+ client.projects.getProjects({
+ ...(teamId && { teamId }),
+ limit: "100",
+ ...(from && { from }),
+ }),
+ VercelSchemas.getProjects,
+ { context: "getVercelProjects" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
const projects = Array.isArray(response)
? response
@@ -1088,6 +1082,111 @@ export class VercelIntegrationRepository {
);
}
+ static upsertEnvVarForCustomEnvironment(params: {
+ orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
+ vercelProjectId: string;
+ teamId: string | null;
+ key: string;
+ value: string;
+ customEnvironmentId: string;
+ type: "sensitive" | "encrypted" | "plain";
+ }): ResultAsync {
+ return this.getVercelClient(params.orgIntegration).andThen((client) =>
+ ResultAsync.fromPromise(
+ (async () => {
+ const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
+
+ const existingEnvs = await callVercelWithRecovery(
+ client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ }),
+ VercelSchemas.filterProjectEnvs,
+ { context: "upsertEnvVarForCustomEnvironment" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
+
+ const envs = extractVercelEnvs(existingEnvs);
+
+ const existingEnv = envs.find((env) => {
+ if (env.key !== key) return false;
+ return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
+ });
+
+ if (existingEnv && existingEnv.id) {
+ await client.projects.editProjectEnv({
+ idOrName: vercelProjectId,
+ id: existingEnv.id,
+ ...(teamId && { teamId }),
+ requestBody: {
+ value,
+ type,
+ },
+ });
+ } else {
+ await client.projects.createProjectEnv({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ requestBody: {
+ key,
+ value,
+ type,
+ customEnvironmentIds: [customEnvironmentId],
+ } as any,
+ });
+ }
+ })(),
+ (error) => toVercelApiError(error)
+ )
+ );
+ }
+
+ static removeEnvVarForCustomEnvironment(params: {
+ orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
+ vercelProjectId: string;
+ teamId: string | null;
+ key: string;
+ customEnvironmentId: string;
+ }): ResultAsync {
+ return this.getVercelClient(params.orgIntegration).andThen((client) =>
+ ResultAsync.fromPromise(
+ (async () => {
+ const { vercelProjectId, teamId, key, customEnvironmentId } = params;
+
+ const existingEnvs = await callVercelWithRecovery(
+ client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ }),
+ VercelSchemas.filterProjectEnvs,
+ { context: "removeEnvVarForCustomEnvironment" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
+
+ const envs = extractVercelEnvs(existingEnvs);
+
+ const existingEnv = envs.find((env) => {
+ if (env.key !== key) return false;
+ return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
+ });
+
+ if (existingEnv && existingEnv.id) {
+ await client.projects.batchRemoveProjectEnv({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ requestBody: { ids: [existingEnv.id] },
+ });
+ }
+ })(),
+ (error) => toVercelApiError(error)
+ )
+ );
+ }
+
static pullEnvVarsFromVercel(params: {
projectId: string;
vercelProjectId: string;
@@ -1416,10 +1515,17 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] };
}
- const existingEnvs = await client.projects.filterProjectEnvs({
- idOrName: vercelProjectId,
- ...(teamId && { teamId }),
- });
+ const existingEnvs = await callVercelWithRecovery(
+ client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ }),
+ VercelSchemas.filterProjectEnvs,
+ { context: "batchUpsertVercelEnvVars" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
const existingEnvsList = extractVercelEnvs(existingEnvs);
@@ -1541,10 +1647,17 @@ export class VercelIntegrationRepository {
}): Promise {
const { client, vercelProjectId, teamId, key } = params;
- const existingEnvs = await client.projects.filterProjectEnvs({
- idOrName: vercelProjectId,
- ...(teamId && { teamId }),
- });
+ const existingEnvs = await callVercelWithRecovery(
+ client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ }),
+ VercelSchemas.filterProjectEnvs,
+ { context: "removeAllVercelEnvVarsByKey" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
const envs = extractVercelEnvs(existingEnvs);
const idsToRemove = envs
@@ -1573,10 +1686,17 @@ export class VercelIntegrationRepository {
}): Promise {
const { client, vercelProjectId, teamId, key, value, target, type } = params;
- const existingEnvs = await client.projects.filterProjectEnvs({
- idOrName: vercelProjectId,
- ...(teamId && { teamId }),
- });
+ const existingEnvs = await callVercelWithRecovery(
+ client.projects.filterProjectEnvs({
+ idOrName: vercelProjectId,
+ ...(teamId && { teamId }),
+ }),
+ VercelSchemas.filterProjectEnvs,
+ { context: "upsertVercelEnvVar" }
+ ).match(
+ (val) => val,
+ (err) => { throw err; }
+ );
const envs = extractVercelEnvs(existingEnvs);
@@ -1620,14 +1740,16 @@ export class VercelIntegrationRepository {
teamId?: string | null
): ResultAsync {
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
- return wrapVercelCall(
+ return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {},
}),
+ VercelSchemas.updateProject,
"Failed to get Vercel project autoAssignCustomDomains",
- { vercelProjectId, teamId }
+ { vercelProjectId, teamId },
+ toVercelApiError
).map((project) => project.autoAssignCustomDomains ?? null);
}
diff --git a/apps/webapp/app/models/vercelSdkRecovery.server.ts b/apps/webapp/app/models/vercelSdkRecovery.server.ts
new file mode 100644
index 0000000000..2fc2fb32b6
--- /dev/null
+++ b/apps/webapp/app/models/vercelSdkRecovery.server.ts
@@ -0,0 +1,195 @@
+import { z } from "zod";
+import { ResultAsync, okAsync, errAsync } from "neverthrow";
+import { logger } from "~/services/logger.server";
+import type { VercelApiError } from "./vercelIntegration.server";
+
+// ---------------------------------------------------------------------------
+// Recovery utilities for Vercel SDK validation errors
+// ---------------------------------------------------------------------------
+//
+// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
+// schemas. When the API returns valid data but a field doesn't match the SDK's
+// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
+// thrown — even though the response contains all the data we need.
+//
+// Error hierarchy:
+// VercelError.body → raw HTTP body text (HTTP errors — never recover)
+// ResponseValidationError.rawValue → parsed JSON that failed validation
+// SDKValidationError.rawValue → same pattern, different base class
+//
+// Recovery: gate on validation error type → extract rawValue → validate → return.
+// ---------------------------------------------------------------------------
+
+/**
+ * Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
+ *
+ * ResponseValidationError and SDKValidationError both carry `rawValue` with the
+ * parsed JSON that failed schema validation. VercelError (HTTP errors) carries
+ * `body` instead — we must NOT recover from those since the response is an error
+ * payload, not the data we asked for.
+ */
+function isValidationError(error: unknown): boolean {
+ if (!error || typeof error !== "object") return false;
+ if (!(error instanceof Error)) return false;
+
+ return (
+ error.constructor.name === "ResponseValidationError" ||
+ error.constructor.name === "SDKValidationError" ||
+ "rawValue" in error
+ );
+}
+
+function extractRawValue(error: unknown): unknown | undefined {
+ if (!error || typeof error !== "object") return undefined;
+ if ("rawValue" in error) {
+ return (error as { rawValue: unknown }).rawValue;
+ }
+ return undefined;
+}
+
+/**
+ * Attempt to recover usable data from a Vercel SDK error.
+ *
+ * Returns the validated data on success, or `undefined` if recovery fails.
+ */
+export function recoverFromVercelSdkError(
+ error: unknown,
+ schema: z.ZodType,
+ options?: { context?: string }
+): T | undefined {
+ if (!isValidationError(error)) return undefined;
+
+ const raw = extractRawValue(error);
+ if (raw === undefined) return undefined;
+
+ const result = schema.safeParse(raw);
+ if (!result.success) return undefined;
+
+ logger.warn("Recovered data from Vercel SDK validation error", {
+ context: options?.context,
+ errorMessage: error instanceof Error ? error.message : String(error),
+ errorType: error?.constructor?.name,
+ });
+
+ return result.data;
+}
+
+/**
+ * Wrap a Vercel SDK promise with automatic recovery on validation errors.
+ *
+ * On success: returns the SDK result as-is.
+ * On error: attempts recovery via rawValue + schema validation (validation errors only).
+ */
+export function callVercelWithRecovery(
+ sdkCall: Promise,
+ schema: z.ZodType,
+ options?: { context?: string }
+): ResultAsync {
+ return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
+ const recovered = recoverFromVercelSdkError(error, schema, options);
+ if (recovered !== undefined) {
+ return okAsync(recovered);
+ }
+ return errAsync(error);
+ });
+}
+
+/**
+ * Drop-in replacement for `wrapVercelCall` with SDK error recovery.
+ *
+ * Wraps a Vercel SDK promise in ResultAsync with structured error logging,
+ * attempting to recover from validation errors before treating as failure.
+ */
+export function wrapVercelCallWithRecovery(
+ promise: Promise,
+ schema: z.ZodType,
+ message: string,
+ context: Record,
+ toError: (error: unknown) => VercelApiError
+): ResultAsync {
+ return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
+ const apiError = toError(error);
+ logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
+ return apiError;
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Minimal Zod schemas — validate only the fields we actually use.
+// All use .passthrough() to preserve extra fields from the API response.
+// ---------------------------------------------------------------------------
+
+export const VercelSchemas = {
+ getTeam: z.object({ slug: z.string() }).passthrough(),
+
+ getAuthUser: z
+ .object({ user: z.object({ username: z.string() }).passthrough() })
+ .passthrough(),
+
+ getCustomEnvironments: z
+ .object({
+ environments: z
+ .array(
+ z
+ .object({
+ id: z.string(),
+ slug: z.string(),
+ description: z.string().optional(),
+ branchMatcher: z.unknown().optional(),
+ })
+ .passthrough()
+ )
+ .optional(),
+ })
+ .passthrough(),
+
+ filterProjectEnvs: z
+ .union([
+ z
+ .object({
+ envs: z.array(z.record(z.unknown())),
+ pagination: z.unknown().optional(),
+ })
+ .passthrough(),
+ z.array(z.record(z.unknown())),
+ ])
+ .transform((val) => (Array.isArray(val) ? { envs: val } : val)),
+
+ getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
+
+ getProjects: z.union([
+ z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
+ z
+ .object({
+ projects: z.array(
+ z.object({ id: z.string(), name: z.string() }).passthrough()
+ ),
+ pagination: z.unknown().optional(),
+ })
+ .passthrough(),
+ ]),
+
+ listSharedEnvVariable: z
+ .object({
+ data: z
+ .array(
+ z
+ .object({
+ id: z.string().optional(),
+ key: z.string().optional(),
+ type: z.string().optional(),
+ target: z.unknown().optional(),
+ value: z.string().optional(),
+ })
+ .passthrough()
+ )
+ .optional(),
+ })
+ .passthrough(),
+
+ getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
+
+ updateProject: z
+ .object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
+ .passthrough(),
+} as const;
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
index 26e9ad5b3b..10595f064a 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx
@@ -244,6 +244,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
+ // Get the previous staging environment before updating
+ const previousIntegration = await vercelService.getVercelProjectIntegration(project.id);
+ const previousStagingEnvId =
+ previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment?.environmentId ?? null;
+ const newStagingEnvId = parsedStagingEnv?.environmentId ?? null;
+
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
atomicBuilds,
pullEnvVarsBeforeBuild,
@@ -252,6 +258,15 @@ export async function action({ request, params }: ActionFunctionArgs) {
});
if (result) {
+ // Sync staging TRIGGER_SECRET_KEY if the custom environment changed
+ if (previousStagingEnvId !== newStagingEnvId) {
+ await vercelService.syncStagingKeyForCustomEnvironment(
+ project.id,
+ previousStagingEnvId,
+ newStagingEnvId
+ );
+ }
+
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
}
@@ -321,6 +336,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
});
if (result) {
+ // During onboarding there's no previous custom environment — just upsert
+ await vercelService.syncStagingKeyForCustomEnvironment(
+ project.id,
+ null,
+ parsedStagingEnv?.environmentId ?? null
+ );
return json({ success: true });
}
diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts
index d9b70eae3a..e2c118ffd3 100644
--- a/apps/webapp/app/services/vercelIntegration.server.ts
+++ b/apps/webapp/app/services/vercelIntegration.server.ts
@@ -347,6 +347,81 @@ export class VercelIntegrationService {
};
}
+ async syncStagingKeyForCustomEnvironment(
+ projectId: string,
+ previousCustomEnvironmentId?: string | null,
+ newCustomEnvironmentId?: string | null
+ ) {
+ const existing = await this.getVercelProjectIntegration(projectId);
+ if (!existing) {
+ return;
+ }
+
+ const orgIntegration =
+ await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
+ if (!orgIntegration) {
+ return;
+ }
+
+ const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
+ const vercelProjectId = existing.parsedIntegrationData.vercelProjectId;
+
+ // Remove the key from the old custom environment (if it changed or was removed)
+ if (previousCustomEnvironmentId && previousCustomEnvironmentId !== newCustomEnvironmentId) {
+ const removeResult = await VercelIntegrationRepository.removeEnvVarForCustomEnvironment({
+ orgIntegration,
+ vercelProjectId,
+ teamId,
+ key: "TRIGGER_SECRET_KEY",
+ customEnvironmentId: previousCustomEnvironmentId,
+ });
+
+ if (removeResult.isErr()) {
+ logger.error("Failed to remove staging TRIGGER_SECRET_KEY from previous custom environment", {
+ projectId,
+ previousCustomEnvironmentId,
+ error: removeResult.error.message,
+ });
+ }
+ }
+
+ // Create/update the key for the new custom environment
+ if (newCustomEnvironmentId) {
+ const stagingEnv = await this.#prismaClient.runtimeEnvironment.findFirst({
+ where: {
+ projectId,
+ type: "STAGING",
+ },
+ select: {
+ apiKey: true,
+ },
+ });
+
+ if (!stagingEnv) {
+ logger.warn("No STAGING runtime environment found for project", { projectId });
+ return;
+ }
+
+ const upsertResult = await VercelIntegrationRepository.upsertEnvVarForCustomEnvironment({
+ orgIntegration,
+ vercelProjectId,
+ teamId,
+ key: "TRIGGER_SECRET_KEY",
+ value: stagingEnv.apiKey,
+ customEnvironmentId: newCustomEnvironmentId,
+ type: "encrypted",
+ });
+
+ if (upsertResult.isErr()) {
+ logger.error("Failed to sync staging TRIGGER_SECRET_KEY to custom environment", {
+ projectId,
+ newCustomEnvironmentId,
+ error: upsertResult.error.message,
+ });
+ }
+ }
+ }
+
async updateSyncEnvVarsMapping(
projectId: string,
syncEnvVarsMapping: SyncEnvVarsMapping
diff --git a/docs/vercel-integration.mdx b/docs/vercel-integration.mdx
index 6cc3b91b94..bc5fefc032 100644
--- a/docs/vercel-integration.mdx
+++ b/docs/vercel-integration.mdx
@@ -113,6 +113,34 @@ You can control sync behavior per-variable from your project's Vercel settings.
Atomic deployments ensure your Vercel app and Trigger.dev tasks are deployed in sync. When enabled, Trigger.dev gates your Vercel deployment until the task build completes, then triggers a Vercel redeployment with the correct `TRIGGER_VERSION` set. This guarantees your app always uses the matching version of your tasks.
+```mermaid
+sequenceDiagram
+ participant Dev as Developer
+ participant GH as GitHub
+ participant V as Vercel
+ participant TD as Trigger.dev
+
+ Dev->>GH: Push code
+ GH->>V: Webhook: new commit
+ V->>V: Start deployment
+ V->>TD: Deployment created
+ Dev->>GH: Create pull request
+ GH->>TD: Pull request created
+ TD->>TD: Start task build
+
+ V->>TD: Deployment check
+ TD-->>V: Check pending (gate deployment)
+ TD->>TD: Build completes
+ TD->>V: Set TRIGGER_VERSION env var
+ TD->>V: Trigger redeployment
+ V->>V: Redeploy with correct TRIGGER_VERSION
+ V->>TD: Deployment check (redeployment)
+ TD-->>V: Check passed
+ V->>V: Promote deployment
+ V->>TD: Deployment promoted
+ TD->>TD: Promote build
+```
+
Atomic deployments are enabled for the production environment by default.