From 494d1bd4900f6d91e49cda2a513d41cbf491a16f Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 7 Aug 2026 15:51:25 +0200 Subject: [PATCH 1/2] feat(vercel): automatic version skew protection at connect + atomic deployments deprecation Connecting a Vercel project now writes TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1 (plain, create-if-absent only - an existing value, including "0", is never touched; presence is target-containment aware, branch-scoped records do not count, a truncated env listing skips the write). The onboarding wizard no longer offers automatic atomic deployments (default off); the settings row is labelled Deprecated and enabling it requires confirming a dialog that points to task version skew protection and the docs (TRI-13001). --- .../vercel-version-skew-protection.md | 6 + .../integrations/VercelBuildSettings.tsx | 117 ++++++--- .../integrations/VercelOnboardingModal.tsx | 5 +- .../app/components/primitives/Badge.tsx | 12 +- .../app/models/vercelIntegration.server.ts | 223 ++++++++++++++++++ ...cts.$projectParam.env.$envParam.vercel.tsx | 105 ++++++++- .../app/services/vercelIntegration.server.ts | 65 +++-- .../app/v3/environmentVariableRules.server.ts | 7 +- .../vercel/vercelProjectIntegrationSchema.ts | 12 +- .../test/environmentVariableRules.test.ts | 6 + 10 files changed, 500 insertions(+), 58 deletions(-) create mode 100644 .server-changes/vercel-version-skew-protection.md diff --git a/.server-changes/vercel-version-skew-protection.md b/.server-changes/vercel-version-skew-protection.md new file mode 100644 index 00000000000..08c67b61ab5 --- /dev/null +++ b/.server-changes/vercel-version-skew-protection.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +New Vercel connections now get version skew protection turned on automatically, so each run uses the task version its deployment shipped with. Automatic atomic deployments are deprecated and no longer offered when you connect a project, but stay available in your Vercel integration settings. diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index fc45d2f6019..e3be9a4f90e 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -1,5 +1,6 @@ import { Switch } from "~/components/primitives/Switch"; import { LinkButton } from "~/components/primitives/Buttons"; +import { Badge } from "~/components/primitives/Badge"; import { Label } from "~/components/primitives/Label"; import { SettingsRow, @@ -7,6 +8,7 @@ import { SettingsRowTitle, } from "~/components/primitives/SettingsLayout"; import { cn } from "~/utils/cn"; +import { docsPath } from "~/utils/pathBuilder"; import { Hint } from "~/components/primitives/Hint"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; @@ -17,6 +19,16 @@ import { } from "~/components/environments/EnvironmentLabel"; import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema"; +export const SKEW_PROTECTION_DOCS_PATH = docsPath("deployment/version-skew-protection"); + +const SKEW_PROTECTION_MIN_SDK_VERSION: string | null = "4.5.12"; + +export function skewProtectionVersionRequirement(): string { + return SKEW_PROTECTION_MIN_SDK_VERSION + ? `from SDK and CLI v${SKEW_PROTECTION_MIN_SDK_VERSION} and later` + : "from a recent SDK and CLI — see the docs for the exact version"; +} + type BuildSettingsFieldsProps = { availableEnvSlugs: EnvSlug[]; pullEnvVarsBeforeBuild: EnvSlug[]; @@ -38,6 +50,7 @@ type BuildSettingsFieldsProps = { currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; + showAtomicDeployments?: boolean; layout?: "settings" | "card"; }; @@ -56,6 +69,7 @@ export function BuildSettingsFields({ currentTriggerVersion, currentTriggerVersionFetchFailed, hideSectionToggles, + showAtomicDeployments = true, layout = "card", }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; @@ -126,7 +140,7 @@ export function BuildSettingsFields({ ) : null; const atomicSections = - layout === "settings" ? ( + layout === "settings" && showAtomicDeployments ? ( <>
- Atomic deployments + + + Atomic deployments + + - Promotes your Vercel deployment and your tasks together in Production, so your app - never runs against a mismatched task version. Requires turning off "Auto-assign Custom - Production Domains" on your Vercel project, which Trigger.dev does for you.{" "} + Version skew protection replaces this. It pins every run to the deployment that + triggered it, and works on its own {skewProtectionVersionRequirement()}. Atomic + deployments still work, so turn this off whenever you're ready.{" "} + + Read about version skew protection + + . + + + Atomic deployments promote your Vercel deployment and your tasks together in + Production, so your app never runs against a mismatched task version. This needs + "Auto-assign Custom Production Domains" turned off on your Vercel project, and + Trigger.dev takes care of that for you.{" "}
- +
- 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.{" "} + Version skew protection replaces this, and works on its own{" "} + {skewProtectionVersionRequirement()}.{" "} + + Read about version skew protection + + . + + + Atomic deployments promote your Vercel deployment and your tasks together in Production, + so your app never runs against a mismatched task version. This needs "Auto-assign Custom + Production Domains" turned off on your Vercel project, and Trigger.dev takes care of + that for you.{" "} -
- - + {layout === "card" && + showAtomicDeployments && + atomicBuilds.includes("prod") && + onAutoPromoteChange !== undefined && ( +
+
+ + +
+ + When enabled, the integration automatically promotes the Vercel deployment after the + Trigger.dev build completes. Turn off to manually promote from your Vercel dashboard — + Trigger.dev will then promote automatically once you do. +
- - When enabled, the integration automatically promotes the Vercel deployment after the - Trigger.dev build completes. Turn off to manually promote from your Vercel dashboard — - Trigger.dev will then promote automatically once you do. - -
- )} + )} ); } +function DeprecatedBadge() { + return ( + + Deprecated + + } + content="Use version skew protection instead" + disableHoverableContent + /> + ); +} + function EnvToggleRow({ slug, checked, diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 195f03599fb..f658424b51e 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -235,7 +235,7 @@ export function VercelOnboardingModal({ const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( () => availableEnvSlugsForOnboardingBuildSettings ); - const [atomicBuilds, setAtomicBuilds] = useState(() => ["prod"]); + const [atomicBuilds, setAtomicBuilds] = useState([]); const [discoverEnvVars, setDiscoverEnvVars] = useState( () => availableEnvSlugsForOnboardingBuildSettings ); @@ -1164,7 +1164,7 @@ export function VercelOnboardingModal({
Build Settings - Configure how environment variables are pulled during builds and atomic deployments. + Configure how environment variables are pulled during builds. & { variant?: keyof typeof variants; }; -export function Badge({ className, variant = "default", children, ...props }: BadgeProps) { - return ( -
+export const Badge = React.forwardRef( + ({ className, variant = "default", children, ...props }, ref) => ( +
{children}
- ); -} + ) +); + +Badge.displayName = "Badge"; diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 68d6ebb66b3..03a5ea69368 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -21,6 +21,8 @@ import type { import { shouldSyncEnvVar, envTypeToVercelTarget, + isVercelStandardTarget, + SKEW_PROTECTION_ENV_VAR_KEY, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server"; @@ -48,10 +50,55 @@ function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBod return []; } +function isVercelEnvListComplete(response: FilterProjectEnvsResponseBody): boolean { + if (!("pagination" in response) || !response.pagination) { + return true; + } + + const next = "next" in response.pagination ? response.pagination.next : null; + return !(typeof next === "number" && next > 0); +} + +function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target: string): boolean { + return envs.some((env) => { + if (env.key !== key) return false; + if (typeof env.gitBranch === "string" && env.gitBranch.length > 0) return false; + if (normalizeTarget(env.target).includes(target)) return true; + return (env.customEnvironmentIds ?? []).includes(target); + }); +} + function isVercelSecretType(type: string): boolean { return type === "secret" || type === "sensitive"; } +export type CreateEnvVarsIfAbsentResult = { + written: string[]; + skipped: string[]; + conflicted: string[]; + failed: string[]; + unresolved: string[]; + errors: string[]; +}; + +function extractCreateProjectEnvFailures(response: unknown): string[] { + if (!response || typeof response !== "object" || !("failed" in response)) { + return []; + } + + const failed = (response as { failed?: unknown }).failed; + if (!Array.isArray(failed)) { + return []; + } + + return failed.map((entry) => { + const error = (entry as { error?: { code?: unknown; message?: unknown } } | null)?.error; + const code = typeof error?.code === "string" ? error.code : "unknown"; + const message = typeof error?.message === "string" ? error.message : ""; + return message ? `${code}: ${message}` : code; + }); +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- @@ -999,6 +1046,8 @@ export class VercelIntegrationRepository { environmentType: string; }> = []; + const skewProtectionTargetSet = new Set(); + for (const runtimeEnv of environments) { const vercelTarget = envTypeToVercelTarget( runtimeEnv.type as TriggerEnvironmentType, @@ -1009,6 +1058,10 @@ export class VercelIntegrationRepository { continue; } + for (const target of vercelTarget) { + skewProtectionTargetSet.add(target); + } + envVarsToSync.push({ key: "TRIGGER_SECRET_KEY", value: runtimeEnv.apiKey, @@ -1022,6 +1075,8 @@ export class VercelIntegrationRepository { return { created: 0, updated: 0, errors: [] as string[] }; } + const skewProtectionTargets = Array.from(skewProtectionTargetSet); + await this.removeAllVercelEnvVarsByKey({ client, vercelProjectId: params.vercelProjectId, @@ -1036,6 +1091,25 @@ export class VercelIntegrationRepository { envVars: envVarsToSync, }); + const skewResult = await this.createVercelEnvVarsIfAbsent({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + value: "1", + type: "plain", + targets: skewProtectionTargets, + }); + + if (skewResult.unresolved.length > 0 || skewResult.failed.length > 0) { + logger.error("Skew protection env var did not reach every target at connect", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + ...skewResult, + }); + } + logger.info("Synced API keys to Vercel", { projectId: params.projectId, vercelProjectId: params.vercelProjectId, @@ -1706,6 +1780,155 @@ export class VercelIntegrationRepository { return { created, updated, errors }; } + private static async createVercelEnvVarsIfAbsent(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + type: "sensitive" | "encrypted" | "plain"; + targets: string[]; + }): Promise { + const { client, vercelProjectId, teamId, key, value, type, targets } = params; + + const result: CreateEnvVarsIfAbsentResult = { + written: [], + skipped: [], + conflicted: [], + failed: [], + unresolved: [], + errors: [], + }; + + if (targets.length === 0) { + return result; + } + + const logContext = { key, vercelProjectId, teamId, targets }; + + const existingEnvs = await callVercelWithRecovery( + client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }), + VercelSchemas.filterProjectEnvs, + { context: "createVercelEnvVarsIfAbsent" } + ).match( + (val) => val, + (error) => { + logger.error("Could not read Vercel env vars — skew protection was not written", { + ...logContext, + outcome: "read_failed", + error, + }); + return null; + } + ); + + if (!existingEnvs) { + return { ...result, unresolved: targets, errors: ["Failed to read Vercel env vars"] }; + } + + if (!isVercelEnvListComplete(existingEnvs)) { + logger.error("Vercel env var list was truncated — skew protection was not written", { + ...logContext, + outcome: "list_truncated", + }); + return { ...result, unresolved: targets, errors: ["Vercel env var list was truncated"] }; + } + + const envs = extractVercelEnvs(existingEnvs); + const targetsToCreate: string[] = []; + + for (const target of targets) { + if (hasVercelEnvVarForTarget(envs, key, target)) { + result.skipped.push(target); + } else { + targetsToCreate.push(target); + } + } + + for (const target of targetsToCreate) { + const requestBody = isVercelStandardTarget(target) + ? { key, value, type, target: [target] } + : { key, value, type, customEnvironmentIds: [target] }; + + const createResult = await ResultAsync.fromPromise( + client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody, + }), + (error) => error + ); + + if (createResult.isErr()) { + const errorMsg = `Failed to create ${key} env var for ${target}: ${createResult.error instanceof Error ? createResult.error.message : "Unknown error"}`; + result.failed.push(target); + result.errors.push(errorMsg); + logger.error(errorMsg, { + ...logContext, + target, + outcome: "failed", + error: createResult.error, + }); + continue; + } + + const failures = extractCreateProjectEnvFailures(createResult.value); + + if (failures.length > 0) { + result.conflicted.push(target); + result.errors.push(...failures); + logger.warn("Vercel rejected a skew protection env var record", { + ...logContext, + target, + outcome: "conflict", + failures, + }); + continue; + } + + result.written.push(target); + } + + logger.info("Finished writing Vercel env var", { + ...logContext, + outcome: "attempted", + written: result.written, + skipped: result.skipped, + conflicted: result.conflicted, + failed: result.failed, + }); + + return result; + } + + static ensureEnvVarForCustomEnvironment(params: { + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + type: "sensitive" | "encrypted" | "plain"; + customEnvironmentId: string; + }): ResultAsync { + return this.getVercelClient(params.orgIntegration).andThen((client) => + ResultAsync.fromPromise( + this.createVercelEnvVarsIfAbsent({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + key: params.key, + value: params.value, + type: params.type, + targets: [params.customEnvironmentId], + }), + (error) => toVercelApiError(error) + ) + ); + } + private static async removeAllVercelEnvVarsByKey(params: { client: Vercel; vercelProjectId: string; 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 c5107482490..c18ff4b4f34 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 @@ -12,7 +12,11 @@ import { EnvironmentIcon, environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; -import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; +import { + BuildSettingsFields, + SKEW_PROTECTION_DOCS_PATH, + skewProtectionVersionRequirement, +} from "~/components/integrations/VercelBuildSettings"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { Button } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; @@ -28,6 +32,7 @@ import { SettingsRow, } from "~/components/primitives/SettingsLayout"; import { Spinner } from "~/components/primitives/Spinner"; +import { TextLink } from "~/components/primitives/TextLink"; import { redirectBackWithErrorMessage, redirectWithErrorMessage, @@ -290,6 +295,11 @@ export const action = dashboardAction( ?.environmentId ?? null; const newStagingEnvId = parsedStagingEnv?.environmentId ?? null; + const wasAtomicEnabled = ( + previousIntegration?.parsedIntegrationData.config?.atomicBuilds ?? [] + ).includes("prod"); + const isAtomicBeingEnabled = !wasAtomicEnabled && (atomicBuilds?.includes("prod") ?? false); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { atomicBuilds, pullEnvVarsBeforeBuild, @@ -299,6 +309,40 @@ export const action = dashboardAction( }); if (result) { + if (isAtomicBeingEnabled) { + try { + const orgIntegration = + await VercelIntegrationRepository.findVercelOrgIntegrationForProject(project.id); + + if (orgIntegration) { + const teamId = + await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + const disableResult = await VercelIntegrationRepository.getVercelClient( + orgIntegration + ).andThen((client) => + VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + result.parsedIntegrationData.vercelProjectId, + teamId + ) + ); + + if (disableResult.isErr()) { + logger.warn("Failed to disable autoAssignCustomDomains when enabling atomic", { + projectId: project.id, + error: disableResult.error.message, + }); + } + } + } catch (error) { + logger.error("Errored while disabling autoAssignCustomDomains when enabling atomic", { + projectId: project.id, + error, + }); + } + } + // Sync staging TRIGGER_SECRET_KEY if the custom environment changed if (previousStagingEnvId !== newStagingEnvId) { await vercelService.syncStagingKeyForCustomEnvironment( @@ -744,6 +788,7 @@ function ConnectedVercelProjectForm({ const saveButtonRef = useRef(null); const clearTriggerVersionInputRef = useRef(null); const [showClearDialog, setShowClearDialog] = useState(false); + const [showEnableAtomicDialog, setShowEnableAtomicDialog] = useState(false); // Modal trigger uses the page-load state of atomicBuilds, not whatever changed in-session, // because clearing TRIGGER_VERSION only makes sense when atomic was actually on at load time. @@ -756,6 +801,14 @@ function ConnectedVercelProjectForm({ isAtomicNowDisabled && (Boolean(currentTriggerVersion) || currentTriggerVersionFetchFailed); + const shouldConfirmEnableAtomicOnSave = + !wasAtomicEnabledAtLoad && configValues.atomicBuilds.includes("prod"); + + const submitConfigForm = () => { + const form = document.getElementById("update-vercel-config") as HTMLFormElement | null; + form?.requestSubmit(saveButtonRef.current ?? undefined); + }; + const submitWithClearChoice = (clear: boolean) => { if (clearTriggerVersionInputRef.current) { clearTriggerVersionInputRef.current.value = clear @@ -763,10 +816,12 @@ function ConnectedVercelProjectForm({ : CLEAR_TRIGGER_VERSION_NO; } setShowClearDialog(false); - // Conform owns the form's React ref via {...configForm.props}, so look it up by id - // (set via useForm({ id: "update-vercel-config" })) rather than fighting for the ref. - const form = document.getElementById("update-vercel-config") as HTMLFormElement | null; - form?.requestSubmit(saveButtonRef.current ?? undefined); + submitConfigForm(); + }; + + const confirmEnableAtomic = () => { + setShowEnableAtomicDialog(false); + submitConfigForm(); }; const isConfigLoading = @@ -1040,6 +1095,12 @@ function ConnectedVercelProjectForm({ if (shouldPromptClearOnSave) { event.preventDefault(); setShowClearDialog(true); + return; + } + + if (shouldConfirmEnableAtomicOnSave) { + event.preventDefault(); + setShowEnableAtomicDialog(true); } }} > @@ -1091,6 +1152,40 @@ function ConnectedVercelProjectForm({
+ + + + Turn on atomic deployments? +
+ + Atomic deployments are deprecated. Task version skew protection is the supported way + to stop your app running against a mismatched task version, and it works automatically{" "} + {skewProtectionVersionRequirement()} — with nothing to turn on.{" "} + + Read about version skew protection + + . + + + If you turn atomic deployments on, every release spawns a second Vercel deployment, + and "Auto-assign Custom Production Domains" must stay off on your Vercel project so + Trigger.dev can stage the switch. + + + Turn on atomic deployments + + } + cancelButton={ + + + + } + /> +
+
+
); } diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index 6a8fb26273b..336519af03b 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -20,6 +20,7 @@ import { VercelProjectIntegrationDataSchema, envTypeToSlug, createDefaultVercelIntegrationData, + SKEW_PROTECTION_ENV_VAR_KEY, } from "~/v3/vercel/vercelProjectIntegrationSchema"; export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & { @@ -225,6 +226,9 @@ export class VercelIntegrationService { vercelStagingEnvironment: parsedData.success ? parsedData.data.config.vercelStagingEnvironment : null, + atomicBuildsEnabled: parsedData.success + ? (parsedData.data.config.atomicBuilds ?? []).includes("prod") + : false, }; } @@ -249,6 +253,7 @@ export class VercelIntegrationService { integration: created, wasCreated: true, vercelStagingEnvironment: null, + atomicBuildsEnabled: (integrationData.config.atomicBuilds ?? []).includes("prod"), }; }, { isolationLevel: "Serializable" } @@ -258,7 +263,7 @@ export class VercelIntegrationService { throw new Error("Failed to select Vercel project: transaction returned undefined"); } - const { integration, wasCreated, vercelStagingEnvironment } = txResult; + const { integration, wasCreated, vercelStagingEnvironment, atomicBuildsEnabled } = txResult; const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({ projectId: params.projectId, @@ -272,22 +277,24 @@ export class VercelIntegrationService { : { success: false, errors: [syncResultAsync.error.message] }; if (wasCreated) { - const disableResult = await VercelIntegrationRepository.getVercelClient( - orgIntegration - ).andThen((client) => - VercelIntegrationRepository.disableAutoAssignCustomDomains( - client, - params.vercelProjectId, - teamId - ) - ); + if (atomicBuildsEnabled) { + const disableResult = await VercelIntegrationRepository.getVercelClient( + orgIntegration + ).andThen((client) => + VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + params.vercelProjectId, + teamId + ) + ); - if (disableResult.isErr()) { - logger.warn("Failed to disable autoAssignCustomDomains during project selection", { - projectId: params.projectId, - vercelProjectId: params.vercelProjectId, - error: disableResult.error.message, - }); + if (disableResult.isErr()) { + logger.warn("Failed to disable autoAssignCustomDomains during project selection", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error: disableResult.error.message, + }); + } } logger.info("Vercel project selected and API keys synced", { @@ -424,6 +431,32 @@ export class VercelIntegrationService { error: upsertResult.error.message, }); } + + const skewResult = await VercelIntegrationRepository.ensureEnvVarForCustomEnvironment({ + orgIntegration, + vercelProjectId, + teamId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + value: "1", + type: "plain", + customEnvironmentId: newCustomEnvironmentId, + }); + + if (skewResult.isErr()) { + logger.error("Failed to write skew protection env var to staging custom environment", { + projectId, + newCustomEnvironmentId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + error: skewResult.error.message, + }); + } else if (skewResult.value.unresolved.length > 0 || skewResult.value.failed.length > 0) { + logger.error("Skew protection env var did not reach the staging custom environment", { + projectId, + newCustomEnvironmentId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + ...skewResult.value, + }); + } } } diff --git a/apps/webapp/app/v3/environmentVariableRules.server.ts b/apps/webapp/app/v3/environmentVariableRules.server.ts index 7e6dc2a93d3..f133d7e1245 100644 --- a/apps/webapp/app/v3/environmentVariableRules.server.ts +++ b/apps/webapp/app/v3/environmentVariableRules.server.ts @@ -1,4 +1,5 @@ import { type EnvironmentVariable } from "./environmentVariables/repository"; +import { SKEW_PROTECTION_ENV_VAR_KEY } from "./vercel/vercelProjectIntegrationSchema"; type VariableRule = | { type: "exact"; key: string } @@ -10,7 +11,11 @@ const blacklistedVariables: VariableRule[] = [ { type: "exact", key: "TRIGGER_API_URL" }, ]; -const additionalExternalSyncReservedKeys = ["TRIGGER_VERSION", "TRIGGER_PREVIEW_BRANCH"]; +const additionalExternalSyncReservedKeys = [ + "TRIGGER_VERSION", + "TRIGGER_PREVIEW_BRANCH", + SKEW_PROTECTION_ENV_VAR_KEY, +]; export function isBlacklistedVariable(key: string): boolean { const whitelisted = blacklistedVariables.find((bv) => bv.type === "whitelist" && bv.key === key); diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 2d4d57b33f4..cde9f708163 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -6,6 +6,8 @@ export type EnvSlug = z.infer; export const ALL_ENV_SLUGS: EnvSlug[] = ["dev", "stg", "prod", "preview"]; +export const SKEW_PROTECTION_ENV_VAR_KEY = "TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION"; + const safeJsonParse = Result.fromThrowable( (val: string) => JSON.parse(val) as unknown, () => null @@ -87,7 +89,7 @@ export function createDefaultVercelIntegrationData( ): VercelProjectIntegrationData { return { config: { - atomicBuilds: ["prod"], + atomicBuilds: [], pullEnvVarsBeforeBuild: ["prod", "preview"], discoverEnvVars: ["prod", "preview"], vercelStagingEnvironment: null, @@ -101,6 +103,14 @@ export function createDefaultVercelIntegrationData( }; } +const VERCEL_STANDARD_TARGETS = ["production", "preview", "development"] as const; + +export type VercelStandardTarget = (typeof VERCEL_STANDARD_TARGETS)[number]; + +export function isVercelStandardTarget(target: string): target is VercelStandardTarget { + return (VERCEL_STANDARD_TARGETS as readonly string[]).includes(target); +} + /** * Maps a Trigger.dev environment type to its Vercel target identifier(s). * Returns null for STAGING when no custom environment is configured. diff --git a/apps/webapp/test/environmentVariableRules.test.ts b/apps/webapp/test/environmentVariableRules.test.ts index 0a035218ddd..9f5c8c86a03 100644 --- a/apps/webapp/test/environmentVariableRules.test.ts +++ b/apps/webapp/test/environmentVariableRules.test.ts @@ -5,6 +5,7 @@ import { isReservedForExternalSync, removeBlacklistedVariables, } from "~/v3/environmentVariableRules.server"; +import { SKEW_PROTECTION_ENV_VAR_KEY } from "~/v3/vercel/vercelProjectIntegrationSchema"; describe("removeBlacklistedVariables", () => { it("should remove exact match blacklisted variables", () => { @@ -96,6 +97,11 @@ describe("isReservedForExternalSync", () => { expect(isReservedForExternalSync("TRIGGER_PREVIEW_BRANCH")).toBe(true); }); + it("reserves the skew protection key we set on the customer's Vercel project", () => { + expect(isReservedForExternalSync(SKEW_PROTECTION_ENV_VAR_KEY)).toBe(true); + expect(isReservedForExternalSync("TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION")).toBe(true); + }); + it("does not reserve ordinary user keys", () => { expect(isReservedForExternalSync("DATABASE_URL")).toBe(false); expect(isReservedForExternalSync("MY_API_KEY")).toBe(false); From aa84e5f18d5083a60581242715afdb33e48d8b28 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 21 Aug 2026 15:03:30 +0200 Subject: [PATCH 2/2] chore(webapp): one reader for Vercel project env vars The project env endpoint returns every record in one response: no `limit` parameter, no cursor. Only the team-level shared endpoint pages, and that read already walks its cursor. Measured against a project holding 500 records. The call sites had drifted to three different answers about that. One warned that variables might be missing, one refused to act on a list it believed truncated, and five said nothing at all. The warning fired on nothing and read like a known limitation, which is how it came to be believed. Project env reads now go through readProjectEnvs, which extracts the records and, if Vercel ever does add a cursor here, logs that the read is silently partial instead of implying a cap that does not exist. --- .../app/models/vercelIntegration.server.ts | 43 +++++++++++-------- .../app/models/vercelSdkRecovery.server.ts | 1 + 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 03a5ea69368..cdf43b4108d 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -43,6 +43,23 @@ function normalizeTarget(target: string[] | string | undefined): string[] { return []; } +function readProjectEnvs( + response: unknown, + logContext: Record +): ResponseBodyEnvs[] { + const cursor = (response as { pagination?: { next?: unknown } } | null | undefined)?.pagination + ?.next; + + if (typeof cursor === "number" && cursor > 0) { + logger.error( + "Vercel project env list returned a pagination cursor — this endpoint has always returned every record in one response, so this read is incomplete and needs paginating", + logContext + ); + } + + return extractVercelEnvs(response as FilterProjectEnvsResponseBody); +} + function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBodyEnvs[] { if ("envs" in response && Array.isArray(response.envs)) { return response.envs; @@ -509,19 +526,7 @@ export class VercelIntegrationRepository { { projectId, teamId }, toVercelApiError ).map((response) => { - // Warn if response is paginated (more data exists that we're not fetching) - if ( - "pagination" in response && - response.pagination && - "next" in response.pagination && - response.pagination.next !== null - ) { - logger.warn( - "Vercel filterProjectEnvs returned paginated response - some env vars may be missing", - { projectId, count: response.pagination.count } - ); - } - return extractVercelEnvs(response).map(toVercelEnvironmentVariable); + return readProjectEnvs(response, { projectId, teamId }).map(toVercelEnvironmentVariable); }); } @@ -544,7 +549,7 @@ export class VercelIntegrationRepository { toVercelApiError ).andThen((response) => { // Apply all filters BEFORE decryption to avoid unnecessary API calls - const filteredEnvs = extractVercelEnvs(response).filter((env) => { + const filteredEnvs = readProjectEnvs(response, { projectId, teamId }).filter((env) => { if (target && !normalizeTarget(env.target).includes(target)) return false; if (shouldIncludeKey && !shouldIncludeKey(env.key)) return false; if (isVercelSecretType(env.type)) return false; @@ -1237,7 +1242,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const existingEnv = envs.find((env) => { if (env.key !== key) return false; @@ -1296,7 +1301,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const existingEnv = envs.find((env) => { if (env.key !== key) return false; @@ -1668,7 +1673,7 @@ export class VercelIntegrationRepository { } ); - const existingEnvsList = extractVercelEnvs(existingEnvs); + const existingEnvsList = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const toCreate: Array<{ key: string; @@ -1951,7 +1956,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const idsToRemove = envs.filter((env) => env.key === key && env.id).map((env) => env.id!); if (idsToRemove.length === 0) { @@ -1990,7 +1995,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); // Vercel can have multiple env vars with the same key but different targets const existingEnv = envs.find((existing) => { diff --git a/apps/webapp/app/models/vercelSdkRecovery.server.ts b/apps/webapp/app/models/vercelSdkRecovery.server.ts index 4def25124cd..95c5e2da0e4 100644 --- a/apps/webapp/app/models/vercelSdkRecovery.server.ts +++ b/apps/webapp/app/models/vercelSdkRecovery.server.ts @@ -147,6 +147,7 @@ export const VercelSchemas = { .object({ envs: z.array(z.record(z.unknown())), pagination: z.unknown().optional(), + hiddenProductionEnvCount: z.number().optional(), }) .passthrough(), z.array(z.record(z.unknown())),