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..d8e9f3fe3f8 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -23,6 +23,12 @@ 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; + /** 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; }; @@ -39,6 +45,8 @@ export function BuildSettingsFields({ disabledEnvSlugs, autoPromote, onAutoPromoteChange, + currentTriggerVersion, + currentTriggerVersionFetchFailed, hideSectionToggles, }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; @@ -208,6 +216,20 @@ export function BuildSettingsFields({ . + {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in Vercel + 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 82bedc6430f..9b553655671 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, }); } @@ -1061,7 +1061,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", { @@ -1115,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) ) @@ -1709,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 4a57e3ec0ef..4fa08122adc 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -42,6 +42,13 @@ 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; + /** 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 = { @@ -248,13 +255,17 @@ export class VercelSettingsPresenter extends BasePresenter { customEnvironments: VercelCustomEnvironment[]; autoAssignCustomDomains: boolean | null; vercelManageAccessUrl?: string; + currentTriggerVersion: string | null; + currentTriggerVersionFetchFailed: boolean; }> => { if (!orgIntegration) { - return { customEnvironments: [], autoAssignCustomDomains: null }; + return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false }; } const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration); if (clientResult.isErr()) { - return { customEnvironments: [], autoAssignCustomDomains: 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); @@ -275,10 +286,10 @@ export class VercelSettingsPresenter extends BasePresenter { } if (!connectedProject) { - return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl }; + return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false }; } - const [customEnvsResult, autoAssignResult] = await Promise.all([ + const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([ VercelIntegrationRepository.getVercelCustomEnvironments( client, connectedProject.vercelProjectId, @@ -289,18 +300,44 @@ export class VercelSettingsPresenter extends BasePresenter { connectedProject.vercelProjectId, teamId ), + VercelIntegrationRepository.getVercelEnvironmentVariableValues( + client, + connectedProject.vercelProjectId, + teamId, + "production", + (key) => key === "TRIGGER_VERSION" + ), ]); + + 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 { + currentTriggerVersionFetchFailed = true; + logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — surfacing as unknown", { + projectId, + vercelProjectId: connectedProject.vercelProjectId, + error: triggerVersionResult.error.message, + }); + } + return { customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [], autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null, vercelManageAccessUrl, + currentTriggerVersion, + currentTriggerVersionFetchFailed, }; }; return fromPromise( fetchVercelData(), (error) => ({ type: "other" as const, cause: error }) - ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({ + ).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion, currentTriggerVersionFetchFailed }) => ({ enabled: true, hasOrgIntegration, authInvalid: false, @@ -311,6 +348,8 @@ export class VercelSettingsPresenter extends BasePresenter { customEnvironments, 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 a37d85b0a56..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 @@ -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; @@ -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,6 +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 === CLEAR_TRIGGER_VERSION_YES), }); const DisconnectVercelFormSchema = z.object({ @@ -243,6 +253,7 @@ export async function action({ request, params }: ActionFunctionArgs) { discoverEnvVars, vercelStagingEnvironment, autoPromote, + clearTriggerVersion, } = submission.value; const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); @@ -271,6 +282,21 @@ 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")) { + 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"); } @@ -573,6 +599,8 @@ function ConnectedVercelProjectForm({ hasPreviewEnvironment, customEnvironments, autoAssignCustomDomains, + currentTriggerVersion, + currentTriggerVersionFetchFailed, organizationSlug, projectSlug, environmentSlug, @@ -582,6 +610,8 @@ function ConnectedVercelProjectForm({ hasPreviewEnvironment: boolean; customEnvironments: Array<{ id: string; slug: string }>; autoAssignCustomDomains: boolean | null; + currentTriggerVersion: string | null; + currentTriggerVersionFetchFailed: boolean; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -645,6 +675,34 @@ 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. + // 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) || currentTriggerVersionFetchFailed); + + const submitWithClearChoice = (clear: boolean) => { + if (clearTriggerVersionInputRef.current) { + 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 + // (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 +800,13 @@ function ConnectedVercelProjectForm({ name="autoPromote" value={String(configValues.autoPromote)} /> + {/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */} +
@@ -819,6 +884,8 @@ function ConnectedVercelProjectForm({ onAutoPromoteChange={(value) => setConfigValues((prev) => ({ ...prev, autoPromote: value })) } + currentTriggerVersion={currentTriggerVersion} + currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} hideSectionToggles /> @@ -862,12 +929,19 @@ function ConnectedVercelProjectForm({ { + if (shouldPromptClearOnSave) { + event.preventDefault(); + setShowClearDialog(true); + } + }} > Save @@ -875,6 +949,58 @@ function ConnectedVercelProjectForm({ />
+ + + + Clear TRIGGER_VERSION from Vercel? +
+ {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 + Vercel deploys will continue using this pinned version. We recommend clearing it. + + + + +
+ } + cancelButton={ + + + + } + /> + +
+
); } @@ -948,6 +1074,8 @@ function VercelSettingsPanel({ hasPreviewEnvironment={data.hasPreviewEnvironment} 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 cf9e634afbb..286e9974054 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -409,7 +409,7 @@ export class VercelIntegrationService { key: "TRIGGER_SECRET_KEY", value: stagingEnv.apiKey, customEnvironmentId: newCustomEnvironmentId, - type: "encrypted", + type: "sensitive", }); if (upsertResult.isErr()) { @@ -714,6 +714,102 @@ export class VercelIntegrationService { }); } + /** + * 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 false; + } + + 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 false; + } + 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 false; + } + + 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 false; + } + + 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 true; + } + + 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 false; + } + + logger.info("Cleared TRIGGER_VERSION from Vercel production", { + projectId, + vercelProjectId, + }); + return true; + } + async disconnectVercelProject(projectId: string): Promise { const existing = await this.getVercelProjectIntegration(projectId); if (!existing) {