From 4685e313ca6a33740271142771d488312f3814b2 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 18 May 2026 19:18:32 +0200 Subject: [PATCH 1/2] feat(webapp): prompt to clear TRIGGER_VERSION on disabling Vercel atomic deployments Show the currently pinned TRIGGER_VERSION env var on Vercel production under the Atomic deployments toggle on the Vercel integration settings page. When the user disables atomic deployments and a pinned version is present, show a confirmation modal on Save asking whether to also clear TRIGGER_VERSION from Vercel. Leaving it pinned without atomic deployments means future Vercel deploys silently keep using the old version, so the modal makes the choice explicit. Also mark TRIGGER_SECRET_KEY writes to Vercel as `sensitive` instead of `encrypted` so the API key value can no longer be read back from the Vercel dashboard or API once written. Existing encrypted keys retain their type until recreated (Vercel rejects in-place type changes); TODOs flag the edit-based call sites for a follow-up. --- ...el-atomic-disable-clear-trigger-version.md | 9 ++ .../integrations/VercelBuildSettings.tsx | 11 +++ .../app/models/vercelIntegration.server.ts | 8 +- .../v3/VercelSettingsPresenter.server.ts | 38 +++++++- ...cts.$projectParam.env.$envParam.vercel.tsx | 92 +++++++++++++++++- .../app/services/vercelIntegration.server.ts | 94 ++++++++++++++++++- 6 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 .server-changes/vercel-atomic-disable-clear-trigger-version.md diff --git a/.server-changes/vercel-atomic-disable-clear-trigger-version.md b/.server-changes/vercel-atomic-disable-clear-trigger-version.md new file mode 100644 index 00000000000..3483a080c8b --- /dev/null +++ b/.server-changes/vercel-atomic-disable-clear-trigger-version.md @@ -0,0 +1,9 @@ +--- +area: webapp +type: feature +--- + +Show the currently pinned `TRIGGER_VERSION` under the Atomic deployments toggle on the Vercel +integration settings, and prompt the user to clear it from Vercel production when they disable +atomic deployments. Also mark `TRIGGER_SECRET_KEY` writes to Vercel as `sensitive` so the value +cannot be read back from the Vercel dashboard or API once written. diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index 92d0d0a9992..98fd0ef17ee 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -23,6 +23,9 @@ type BuildSettingsFieldsProps = { disabledEnvSlugs?: Partial>; autoPromote?: boolean; onAutoPromoteChange?: (value: boolean) => void; + /** The currently pinned TRIGGER_VERSION on Vercel production, if any. Shown under the + * Atomic deployments toggle so the user knows what version is set on Vercel right now. */ + currentTriggerVersion?: string | null; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; }; @@ -39,6 +42,7 @@ export function BuildSettingsFields({ disabledEnvSlugs, autoPromote, onAutoPromoteChange, + currentTriggerVersion, hideSectionToggles, }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; @@ -208,6 +212,13 @@ export function BuildSettingsFields({ . + {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in Vercel + production. + + )} {/* Auto promotion — only visible when atomic deployments are on */} diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 82bedc6430f..11feeb59c42 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -960,7 +960,7 @@ export class VercelIntegrationRepository { key: "TRIGGER_SECRET_KEY", value: runtimeEnv.apiKey, target: vercelTarget, - type: "encrypted", + type: "sensitive", environmentType: runtimeEnv.type, }); } @@ -1054,6 +1054,10 @@ export class VercelIntegrationRepository { return; } + // TODO: Vercel rejects type changes on existing env vars (encrypted -> sensitive), + // so for projects whose TRIGGER_SECRET_KEY was created before this change, the + // editProjectEnv call will keep the previous type. Recreate via delete-then-create + // to force the upgrade once we're ready to do it project-wide. await this.upsertVercelEnvVar({ client, vercelProjectId: projectIntegration.externalEntityId, @@ -1061,7 +1065,7 @@ export class VercelIntegrationRepository { key: "TRIGGER_SECRET_KEY", value: params.apiKey, target: vercelTarget, - type: "encrypted", + type: "sensitive", }); logger.info("Synced regenerated API key to Vercel", { diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 4a57e3ec0ef..c8f1165a790 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -42,6 +42,9 @@ export type VercelSettingsResult = { autoAssignCustomDomains?: boolean | null; /** URL to manage Vercel integration access (project sharing) on vercel.com */ vercelManageAccessUrl?: string; + /** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface + * the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */ + currentTriggerVersion?: string | null; }; export type VercelAvailableProject = { @@ -248,13 +251,14 @@ export class VercelSettingsPresenter extends BasePresenter { customEnvironments: VercelCustomEnvironment[]; autoAssignCustomDomains: boolean | null; vercelManageAccessUrl?: string; + currentTriggerVersion: string | null; }> => { if (!orgIntegration) { - return { customEnvironments: [], autoAssignCustomDomains: null }; + return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null }; } const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); if (clientResult.isErr()) { - return { customEnvironments: [], autoAssignCustomDomains: null }; + return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null }; } const client = clientResult.value; const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); @@ -275,10 +279,10 @@ export class VercelSettingsPresenter extends BasePresenter { } if (!connectedProject) { - return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl }; + return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null }; } - const [customEnvsResult, autoAssignResult] = await Promise.all([ + const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([ VercelIntegrationRepository.getVercelCustomEnvironments( client, connectedProject.vercelProjectId, @@ -289,18 +293,41 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject.vercelProjectId, teamId ), + VercelIntegrationRepository.getVercelEnvironmentVariableValues( + client, + connectedProject.vercelProjectId, + teamId, + "production", + (key) => key === "TRIGGER_VERSION" + ), ]); + + let currentTriggerVersion: string | null = null; + if (triggerVersionResult.isOk()) { + const match = triggerVersionResult.value.find( + (envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production") + ); + currentTriggerVersion = match?.value ?? null; + } else { + logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — continuing without it", { + projectId, + vercelProjectId: connectedProject.vercelProjectId, + error: triggerVersionResult.error.message, + }); + } + return { customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [], autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null, vercelManageAccessUrl, + currentTriggerVersion, }; }; return fromPromise( fetchVercelData(), (error) => ({ type: "other" as const, cause: error }) - ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({ + ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion }) => ({ enabled: true, hasOrgIntegration, authInvalid: false, @@ -311,6 +338,7 @@ export class VercelSettingsPresenter extends BasePresenter { customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, + currentTriggerVersion, } as VercelSettingsResult)); }).mapErr((error) => { // Log the error and return a safe fallback 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 a37d85b0a56..d0e63223f55 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 @@ -61,7 +61,7 @@ import { getAvailableEnvSlugsForBuildSettings, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { Result, fromPromise } from "neverthrow"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; export type ConnectedVercelProject = { id: string; @@ -99,6 +99,7 @@ const UpdateVercelConfigFormSchema = z.object({ discoverEnvVars: envSlugArrayField, vercelStagingEnvironment: z.string().nullable().optional(), autoPromote: z.string().optional().transform((val) => val !== "false"), + clearTriggerVersion: z.string().optional().transform((val) => val === "true"), }); const DisconnectVercelFormSchema = z.object({ @@ -243,6 +244,7 @@ export async function action({ request, params }: ActionFunctionArgs) { discoverEnvVars, vercelStagingEnvironment, autoPromote, + clearTriggerVersion, } = submission.value; const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); @@ -271,6 +273,12 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } + // When atomic deployments are being disabled and the user confirmed clearing the pin, + // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. + if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { + await vercelService.clearTriggerVersionFromVercelProduction(project.id); + } + return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); } @@ -573,6 +581,7 @@ function ConnectedVercelProjectForm({ hasPreviewEnvironment, customEnvironments, autoAssignCustomDomains, + currentTriggerVersion, organizationSlug, projectSlug, environmentSlug, @@ -582,6 +591,7 @@ function ConnectedVercelProjectForm({ hasPreviewEnvironment: boolean; customEnvironments: Array<{ id: string; slug: string }>; autoAssignCustomDomains: boolean | null; + currentTriggerVersion: string | null; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -645,6 +655,28 @@ function ConnectedVercelProjectForm({ }, }); + const saveButtonRef = useRef(null); + const clearTriggerVersionInputRef = useRef(null); + const [showClearDialog, setShowClearDialog] = 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. + const wasAtomicEnabledAtLoad = originalAtomicBuilds.includes("prod"); + const isAtomicNowDisabled = !configValues.atomicBuilds.includes("prod"); + const shouldPromptClearOnSave = + wasAtomicEnabledAtLoad && isAtomicNowDisabled && Boolean(currentTriggerVersion); + + const submitWithClearChoice = (clear: boolean) => { + if (clearTriggerVersionInputRef.current) { + clearTriggerVersionInputRef.current.value = clear ? "true" : "false"; + } + 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); + }; + const isConfigLoading = navigation.formData?.get("action") === "update-config" && (navigation.state === "submitting" || navigation.state === "loading"); @@ -742,6 +774,13 @@ function ConnectedVercelProjectForm({ name="autoPromote" value={String(configValues.autoPromote)} /> + {/* Toggled to "true" by the clear-pinned-version modal; defaults to "false". */} +
@@ -819,6 +858,7 @@ function ConnectedVercelProjectForm({ onAutoPromoteChange={(value) => setConfigValues((prev) => ({ ...prev, autoPromote: value })) } + currentTriggerVersion={currentTriggerVersion} hideSectionToggles /> @@ -862,12 +902,19 @@ function ConnectedVercelProjectForm({ { + if (shouldPromptClearOnSave) { + event.preventDefault(); + setShowClearDialog(true); + } + }} > Save @@ -875,6 +922,48 @@ function ConnectedVercelProjectForm({ />
+ + + + Clear TRIGGER_VERSION from Vercel? +
+ + Atomic deployments are being turned off. The{" "} + TRIGGER_VERSION env var on your + Vercel production environment is currently set to{" "} + {currentTriggerVersion}. + + + If you leave it, your Vercel project will stay pinned to this version. Since atomic + deployments will be off, Trigger.dev will no longer update this variable, and future + Vercel deploys will continue using this pinned version. We recommend clearing it. + + + + +
+ } + cancelButton={ + + + + } + /> + +
+
); } @@ -948,6 +1037,7 @@ function VercelSettingsPanel({ hasPreviewEnvironment={data.hasPreviewEnvironment} customEnvironments={data.customEnvironments} autoAssignCustomDomains={data.autoAssignCustomDomains ?? null} + currentTriggerVersion={data.currentTriggerVersion ?? null} organizationSlug={organizationSlug} projectSlug={projectSlug} environmentSlug={environmentSlug} diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index cf9e634afbb..f57852d1d5a 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -402,6 +402,9 @@ export class VercelIntegrationService { return; } + // TODO: Vercel rejects type changes on existing env vars (encrypted -> sensitive), + // so projects whose staging TRIGGER_SECRET_KEY was created before this change will + // retain their previous type until the var is recreated. const upsertResult = await VercelIntegrationRepository.upsertEnvVarForCustomEnvironment({ orgIntegration, vercelProjectId, @@ -409,7 +412,7 @@ export class VercelIntegrationService { key: "TRIGGER_SECRET_KEY", value: stagingEnv.apiKey, customEnvironmentId: newCustomEnvironmentId, - type: "encrypted", + type: "sensitive", }); if (upsertResult.isErr()) { @@ -714,6 +717,95 @@ export class VercelIntegrationService { }); } + async clearTriggerVersionFromVercelProduction(projectId: string): Promise { + const orgIntegration = + await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId); + if (!orgIntegration) { + return; + } + + const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); + if (clientResult.isErr()) { + logger.error("Failed to get Vercel client for TRIGGER_VERSION clear", { + projectId, + error: clientResult.error.message, + }); + return; + } + const client = clientResult.value; + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId, + organizationIntegrationId: orgIntegration.id, + deletedAt: null, + }, + select: { + externalEntityId: true, + }, + }); + + if (!projectIntegration) { + return; + } + + const vercelProjectId = projectIntegration.externalEntityId; + + const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables( + client, + vercelProjectId, + teamId + ); + + if (envVarsResult.isErr()) { + logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION clear", { + projectId, + vercelProjectId, + error: envVarsResult.error.message, + }); + return; + } + + const existingTriggerVersion = envVarsResult.value.find( + (env) => env.key === "TRIGGER_VERSION" && env.target.includes("production") + ); + + if (!existingTriggerVersion) { + logger.info("TRIGGER_VERSION not present on Vercel production — nothing to clear", { + projectId, + vercelProjectId, + }); + return; + } + + const removeResult = await ResultAsync.fromPromise( + client.projects.batchRemoveProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { ids: [existingTriggerVersion.id] }, + }), + (error) => error + ); + + if (removeResult.isErr()) { + logger.error("Failed to clear TRIGGER_VERSION from Vercel production", { + projectId, + vercelProjectId, + error: + removeResult.error instanceof Error + ? removeResult.error.message + : String(removeResult.error), + }); + return; + } + + logger.info("Cleared TRIGGER_VERSION from Vercel production", { + projectId, + vercelProjectId, + }); + } + async disconnectVercelProject(projectId: string): Promise { const existing = await this.getVercelProjectIntegration(projectId); if (!existing) { From 52a64d8daf5443403deb8ed375cd3044054a7003 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 19 May 2026 13:25:14 +0200 Subject: [PATCH 2/2] fix(webapp): address PR feedback for Vercel atomic deployments toggle - Always delete-then-create in upsertVercelEnvVar and upsertEnvVarForCustomEnvironment. Vercel rejects in-place type changes (e.g. encrypted -> sensitive) on editProjectEnv, so the regenerate-API-key and staging-key-remap flows would silently keep the prior type. Drop editProjectEnv entirely; existing var (if any) is removed via batchRemoveProjectEnv and then created fresh, matching the precedent already in syncApiKeysToVercel. - Distinguish a Vercel read failure from "no pin set" in the VercelSettingsPresenter. Add currentTriggerVersionFetchFailed through the loader to the form; the disable- atomic confirmation modal now triggers in both the known-pinned and unknown cases, with copy adapted to ask the user to verify manually when the lookup failed. - clearTriggerVersionFromVercelProduction returns Promise. Route surfaces a partial-success message when the delete fails so users know to clear the env var manually instead of seeing a misleading success toast. - Replace raw "true"/"false" string sentinels for clearTriggerVersion with named constants used consistently across the zod transform, the hidden input default, and the modal submit helper. --- .../integrations/VercelBuildSettings.tsx | 11 +++ .../app/models/vercelIntegration.server.ts | 70 ++++++++----------- .../v3/VercelSettingsPresenter.server.ts | 21 ++++-- ...cts.$projectParam.env.$envParam.vercel.tsx | 62 ++++++++++++---- .../app/services/vercelIntegration.server.ts | 24 ++++--- 5 files changed, 122 insertions(+), 66 deletions(-) diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index 98fd0ef17ee..d8e9f3fe3f8 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -26,6 +26,9 @@ type BuildSettingsFieldsProps = { /** The currently pinned TRIGGER_VERSION on Vercel production, if any. Shown under the * Atomic deployments toggle so the user knows what version is set on Vercel right now. */ currentTriggerVersion?: string | null; + /** True when the Vercel lookup for TRIGGER_VERSION failed. We show this so the user knows + * the pin status is unknown — distinct from "not set". */ + currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; }; @@ -43,6 +46,7 @@ export function BuildSettingsFields({ autoPromote, onAutoPromoteChange, currentTriggerVersion, + currentTriggerVersionFetchFailed, hideSectionToggles, }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; @@ -219,6 +223,13 @@ export function BuildSettingsFields({ production. )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read{" "} + TRIGGER_VERSION from Vercel — + check the Vercel dashboard to confirm the production pin. + + )} {/* Auto promotion — only visible when atomic deployments are on */} diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 11feeb59c42..9b553655671 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1054,10 +1054,6 @@ export class VercelIntegrationRepository { return; } - // TODO: Vercel rejects type changes on existing env vars (encrypted -> sensitive), - // so for projects whose TRIGGER_SECRET_KEY was created before this change, the - // editProjectEnv call will keep the previous type. Recreate via delete-then-create - // to force the upgrade once we're ready to do it project-wide. await this.upsertVercelEnvVar({ client, vercelProjectId: projectIntegration.externalEntityId, @@ -1119,28 +1115,26 @@ export class VercelIntegrationRepository { return (env as any).customEnvironmentIds?.includes(customEnvironmentId); }); + // Always delete-then-create rather than editProjectEnv, because Vercel rejects + // in-place type changes (e.g. encrypted -> sensitive). if (existingEnv && existingEnv.id) { - await client.projects.editProjectEnv({ - idOrName: vercelProjectId, - id: existingEnv.id, - ...(teamId && { teamId }), - requestBody: { - value, - type, - }, - }); - } else { - await client.projects.createProjectEnv({ + await client.projects.batchRemoveProjectEnv({ idOrName: vercelProjectId, ...(teamId && { teamId }), - requestBody: { - key, - value, - type, - customEnvironmentIds: [customEnvironmentId], - } as any, + requestBody: { ids: [existingEnv.id] }, }); } + + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + key, + value, + type, + customEnvironmentIds: [customEnvironmentId], + } as any, + }); })(), (error) => toVercelApiError(error) ) @@ -1713,29 +1707,27 @@ export class VercelIntegrationRepository { return target.length === envTargets.length && target.every((t) => envTargets.includes(t)); }); + // Always delete-then-create rather than editProjectEnv, because Vercel rejects + // in-place type changes (e.g. encrypted -> sensitive). Same approach used by + // syncApiKeysToVercel via removeAllVercelEnvVarsByKey. if (existingEnv && existingEnv.id) { - await client.projects.editProjectEnv({ - idOrName: vercelProjectId, - id: existingEnv.id, - ...(teamId && { teamId }), - requestBody: { - value, - target: target as any, - type, - }, - }); - } else { - await client.projects.createProjectEnv({ + await client.projects.batchRemoveProjectEnv({ idOrName: vercelProjectId, ...(teamId && { teamId }), - requestBody: { - key, - value, - target: target as any, - type, - }, + requestBody: { ids: [existingEnv.id] }, }); } + + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + key, + value, + target: target as any, + type, + }, + }); } static getAutoAssignCustomDomains( diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index c8f1165a790..4fa08122adc 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -45,6 +45,10 @@ export type VercelSettingsResult = { /** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface * the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */ currentTriggerVersion?: string | null; + /** True when the Vercel lookup for TRIGGER_VERSION failed (network/auth/etc). Distinct + * from "no pin set" — the UI uses this to warn the user and still prompt them on disable + * so they can manually verify that production isn't pinned. */ + currentTriggerVersionFetchFailed?: boolean; }; export type VercelAvailableProject = { @@ -252,13 +256,16 @@ export class VercelSettingsPresenter extends BasePresenter { autoAssignCustomDomains: boolean | null; vercelManageAccessUrl?: string; currentTriggerVersion: string | null; + currentTriggerVersionFetchFailed: boolean; }> => { if (!orgIntegration) { - return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null }; + return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false }; } const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); if (clientResult.isErr()) { - return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null }; + // We couldn't even build a Vercel client — treat as fetch failure so the UI + // still prompts the user when they disable atomic deployments. + return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null, currentTriggerVersionFetchFailed: true }; } const client = clientResult.value; const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); @@ -279,7 +286,7 @@ export class VercelSettingsPresenter extends BasePresenter { } if (!connectedProject) { - return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null }; + return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false }; } const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([ @@ -303,13 +310,15 @@ export class VercelSettingsPresenter extends BasePresenter { ]); let currentTriggerVersion: string | null = null; + let currentTriggerVersionFetchFailed = false; if (triggerVersionResult.isOk()) { const match = triggerVersionResult.value.find( (envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production") ); currentTriggerVersion = match?.value ?? null; } else { - logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — continuing without it", { + currentTriggerVersionFetchFailed = true; + logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — surfacing as unknown", { projectId, vercelProjectId: connectedProject.vercelProjectId, error: triggerVersionResult.error.message, @@ -321,13 +330,14 @@ export class VercelSettingsPresenter extends BasePresenter { autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null, vercelManageAccessUrl, currentTriggerVersion, + currentTriggerVersionFetchFailed, }; }; return fromPromise( fetchVercelData(), (error) => ({ type: "other" as const, cause: error }) - ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion }) => ({ + ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion, currentTriggerVersionFetchFailed }) => ({ enabled: true, hasOrgIntegration, authInvalid: false, @@ -339,6 +349,7 @@ export class VercelSettingsPresenter extends BasePresenter { autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion, + currentTriggerVersionFetchFailed, } as VercelSettingsResult)); }).mapErr((error) => { // Log the error and return a safe fallback 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 d0e63223f55..fdc6dfd8242 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 @@ -92,6 +92,12 @@ function parseVercelStagingEnvironment( ); } +// Sentinel values for the clearTriggerVersion hidden input. Used by the schema transform, +// the input's defaultValue, and the modal's submit helper — keep all three reading the same +// constants so they cannot drift. +const CLEAR_TRIGGER_VERSION_YES = "true"; +const CLEAR_TRIGGER_VERSION_NO = "false"; + const UpdateVercelConfigFormSchema = z.object({ action: z.literal("update-config"), atomicBuilds: envSlugArrayField, @@ -99,7 +105,10 @@ const UpdateVercelConfigFormSchema = z.object({ discoverEnvVars: envSlugArrayField, vercelStagingEnvironment: z.string().nullable().optional(), autoPromote: z.string().optional().transform((val) => val !== "false"), - clearTriggerVersion: z.string().optional().transform((val) => val === "true"), + clearTriggerVersion: z + .string() + .optional() + .transform((val) => val === CLEAR_TRIGGER_VERSION_YES), }); const DisconnectVercelFormSchema = z.object({ @@ -275,8 +284,17 @@ export async function action({ request, params }: ActionFunctionArgs) { // When atomic deployments are being disabled and the user confirmed clearing the pin, // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. + // If the Vercel API call fails we still consider the settings save itself successful, + // but tell the user so they can clear the env var manually from the Vercel dashboard. if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { - await vercelService.clearTriggerVersionFromVercelProduction(project.id); + const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id); + if (!cleared) { + return redirectWithErrorMessage( + settingsPath, + request, + "Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings." + ); + } } return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); @@ -582,6 +600,7 @@ function ConnectedVercelProjectForm({ customEnvironments, autoAssignCustomDomains, currentTriggerVersion, + currentTriggerVersionFetchFailed, organizationSlug, projectSlug, environmentSlug, @@ -592,6 +611,7 @@ function ConnectedVercelProjectForm({ customEnvironments: Array<{ id: string; slug: string }>; autoAssignCustomDomains: boolean | null; currentTriggerVersion: string | null; + currentTriggerVersionFetchFailed: boolean; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -661,14 +681,20 @@ function ConnectedVercelProjectForm({ // 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. + // If the Vercel lookup failed we still prompt — we don't know whether a pin exists, so the + // user needs to make the call explicitly rather than silently leaving prod pinned. const wasAtomicEnabledAtLoad = originalAtomicBuilds.includes("prod"); const isAtomicNowDisabled = !configValues.atomicBuilds.includes("prod"); const shouldPromptClearOnSave = - wasAtomicEnabledAtLoad && isAtomicNowDisabled && Boolean(currentTriggerVersion); + wasAtomicEnabledAtLoad && + isAtomicNowDisabled && + (Boolean(currentTriggerVersion) || currentTriggerVersionFetchFailed); const submitWithClearChoice = (clear: boolean) => { if (clearTriggerVersionInputRef.current) { - clearTriggerVersionInputRef.current.value = clear ? "true" : "false"; + clearTriggerVersionInputRef.current.value = clear + ? CLEAR_TRIGGER_VERSION_YES + : CLEAR_TRIGGER_VERSION_NO; } setShowClearDialog(false); // Conform owns the form's React ref via {...configForm.props}, so look it up by id @@ -774,11 +800,11 @@ function ConnectedVercelProjectForm({ name="autoPromote" value={String(configValues.autoPromote)} /> - {/* Toggled to "true" by the clear-pinned-version modal; defaults to "false". */} + {/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */} @@ -859,6 +885,7 @@ function ConnectedVercelProjectForm({ setConfigValues((prev) => ({ ...prev, autoPromote: value })) } currentTriggerVersion={currentTriggerVersion} + currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} hideSectionToggles /> @@ -927,12 +954,22 @@ function ConnectedVercelProjectForm({ Clear TRIGGER_VERSION from Vercel?
- - Atomic deployments are being turned off. The{" "} - TRIGGER_VERSION env var on your - Vercel production environment is currently set to{" "} - {currentTriggerVersion}. - + {currentTriggerVersion ? ( + + Atomic deployments are being turned off. The{" "} + TRIGGER_VERSION env var on + your Vercel production environment is currently set to{" "} + {currentTriggerVersion}. + + ) : ( + + Atomic deployments are being turned off. We couldn't reach Vercel to confirm + whether{" "} + TRIGGER_VERSION is currently + set on your Vercel production environment, so please verify in the Vercel + dashboard. + + )} If you leave it, your Vercel project will stay pinned to this version. Since atomic deployments will be off, Trigger.dev will no longer update this variable, and future @@ -1038,6 +1075,7 @@ function VercelSettingsPanel({ customEnvironments={data.customEnvironments} autoAssignCustomDomains={data.autoAssignCustomDomains ?? null} currentTriggerVersion={data.currentTriggerVersion ?? null} + currentTriggerVersionFetchFailed={data.currentTriggerVersionFetchFailed ?? false} organizationSlug={organizationSlug} projectSlug={projectSlug} environmentSlug={environmentSlug} diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index f57852d1d5a..286e9974054 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -402,9 +402,6 @@ export class VercelIntegrationService { return; } - // TODO: Vercel rejects type changes on existing env vars (encrypted -> sensitive), - // so projects whose staging TRIGGER_SECRET_KEY was created before this change will - // retain their previous type until the var is recreated. const upsertResult = await VercelIntegrationRepository.upsertEnvVarForCustomEnvironment({ orgIntegration, vercelProjectId, @@ -717,11 +714,17 @@ export class VercelIntegrationService { }); } - async clearTriggerVersionFromVercelProduction(projectId: string): Promise { + /** + * Returns true when TRIGGER_VERSION is no longer pinned on Vercel production after the call + * (either we cleared it or it wasn't set to begin with). Returns false when we failed to + * verify or perform the delete — callers should surface that to the user so they can clear + * it manually. + */ + async clearTriggerVersionFromVercelProduction(projectId: string): Promise { const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId); if (!orgIntegration) { - return; + return false; } const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); @@ -730,7 +733,7 @@ export class VercelIntegrationService { projectId, error: clientResult.error.message, }); - return; + return false; } const client = clientResult.value; const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); @@ -747,7 +750,7 @@ export class VercelIntegrationService { }); if (!projectIntegration) { - return; + return false; } const vercelProjectId = projectIntegration.externalEntityId; @@ -764,7 +767,7 @@ export class VercelIntegrationService { vercelProjectId, error: envVarsResult.error.message, }); - return; + return false; } const existingTriggerVersion = envVarsResult.value.find( @@ -776,7 +779,7 @@ export class VercelIntegrationService { projectId, vercelProjectId, }); - return; + return true; } const removeResult = await ResultAsync.fromPromise( @@ -797,13 +800,14 @@ export class VercelIntegrationService { ? removeResult.error.message : String(removeResult.error), }); - return; + return false; } logger.info("Cleared TRIGGER_VERSION from Vercel production", { projectId, vercelProjectId, }); + return true; } async disconnectVercelProject(projectId: string): Promise {