Skip to content

Commit 52a64d8

Browse files
committed
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<boolean>. 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.
1 parent 4685e31 commit 52a64d8

5 files changed

Lines changed: 122 additions & 66 deletions

File tree

apps/webapp/app/components/integrations/VercelBuildSettings.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type BuildSettingsFieldsProps = {
2626
/** The currently pinned TRIGGER_VERSION on Vercel production, if any. Shown under the
2727
* Atomic deployments toggle so the user knows what version is set on Vercel right now. */
2828
currentTriggerVersion?: string | null;
29+
/** True when the Vercel lookup for TRIGGER_VERSION failed. We show this so the user knows
30+
* the pin status is unknown — distinct from "not set". */
31+
currentTriggerVersionFetchFailed?: boolean;
2932
/** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */
3033
hideSectionToggles?: boolean;
3134
};
@@ -43,6 +46,7 @@ export function BuildSettingsFields({
4346
autoPromote,
4447
onAutoPromoteChange,
4548
currentTriggerVersion,
49+
currentTriggerVersionFetchFailed,
4650
hideSectionToggles,
4751
}: BuildSettingsFieldsProps) {
4852
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
@@ -219,6 +223,13 @@ export function BuildSettingsFields({
219223
production.
220224
</Hint>
221225
)}
226+
{!currentTriggerVersion && currentTriggerVersionFetchFailed && (
227+
<Hint className="pr-6 text-warning">
228+
Couldn't read{" "}
229+
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> from Vercel —
230+
check the Vercel dashboard to confirm the production pin.
231+
</Hint>
232+
)}
222233
</div>
223234

224235
{/* Auto promotion — only visible when atomic deployments are on */}

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,10 +1054,6 @@ export class VercelIntegrationRepository {
10541054
return;
10551055
}
10561056

1057-
// TODO: Vercel rejects type changes on existing env vars (encrypted -> sensitive),
1058-
// so for projects whose TRIGGER_SECRET_KEY was created before this change, the
1059-
// editProjectEnv call will keep the previous type. Recreate via delete-then-create
1060-
// to force the upgrade once we're ready to do it project-wide.
10611057
await this.upsertVercelEnvVar({
10621058
client,
10631059
vercelProjectId: projectIntegration.externalEntityId,
@@ -1119,28 +1115,26 @@ export class VercelIntegrationRepository {
11191115
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
11201116
});
11211117

1118+
// Always delete-then-create rather than editProjectEnv, because Vercel rejects
1119+
// in-place type changes (e.g. encrypted -> sensitive).
11221120
if (existingEnv && existingEnv.id) {
1123-
await client.projects.editProjectEnv({
1124-
idOrName: vercelProjectId,
1125-
id: existingEnv.id,
1126-
...(teamId && { teamId }),
1127-
requestBody: {
1128-
value,
1129-
type,
1130-
},
1131-
});
1132-
} else {
1133-
await client.projects.createProjectEnv({
1121+
await client.projects.batchRemoveProjectEnv({
11341122
idOrName: vercelProjectId,
11351123
...(teamId && { teamId }),
1136-
requestBody: {
1137-
key,
1138-
value,
1139-
type,
1140-
customEnvironmentIds: [customEnvironmentId],
1141-
} as any,
1124+
requestBody: { ids: [existingEnv.id] },
11421125
});
11431126
}
1127+
1128+
await client.projects.createProjectEnv({
1129+
idOrName: vercelProjectId,
1130+
...(teamId && { teamId }),
1131+
requestBody: {
1132+
key,
1133+
value,
1134+
type,
1135+
customEnvironmentIds: [customEnvironmentId],
1136+
} as any,
1137+
});
11441138
})(),
11451139
(error) => toVercelApiError(error)
11461140
)
@@ -1713,29 +1707,27 @@ export class VercelIntegrationRepository {
17131707
return target.length === envTargets.length && target.every((t) => envTargets.includes(t));
17141708
});
17151709

1710+
// Always delete-then-create rather than editProjectEnv, because Vercel rejects
1711+
// in-place type changes (e.g. encrypted -> sensitive). Same approach used by
1712+
// syncApiKeysToVercel via removeAllVercelEnvVarsByKey.
17161713
if (existingEnv && existingEnv.id) {
1717-
await client.projects.editProjectEnv({
1718-
idOrName: vercelProjectId,
1719-
id: existingEnv.id,
1720-
...(teamId && { teamId }),
1721-
requestBody: {
1722-
value,
1723-
target: target as any,
1724-
type,
1725-
},
1726-
});
1727-
} else {
1728-
await client.projects.createProjectEnv({
1714+
await client.projects.batchRemoveProjectEnv({
17291715
idOrName: vercelProjectId,
17301716
...(teamId && { teamId }),
1731-
requestBody: {
1732-
key,
1733-
value,
1734-
target: target as any,
1735-
type,
1736-
},
1717+
requestBody: { ids: [existingEnv.id] },
17371718
});
17381719
}
1720+
1721+
await client.projects.createProjectEnv({
1722+
idOrName: vercelProjectId,
1723+
...(teamId && { teamId }),
1724+
requestBody: {
1725+
key,
1726+
value,
1727+
target: target as any,
1728+
type,
1729+
},
1730+
});
17391731
}
17401732

17411733
static getAutoAssignCustomDomains(

apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export type VercelSettingsResult = {
4545
/** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface
4646
* the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */
4747
currentTriggerVersion?: string | null;
48+
/** True when the Vercel lookup for TRIGGER_VERSION failed (network/auth/etc). Distinct
49+
* from "no pin set" — the UI uses this to warn the user and still prompt them on disable
50+
* so they can manually verify that production isn't pinned. */
51+
currentTriggerVersionFetchFailed?: boolean;
4852
};
4953

5054
export type VercelAvailableProject = {
@@ -252,13 +256,16 @@ export class VercelSettingsPresenter extends BasePresenter {
252256
autoAssignCustomDomains: boolean | null;
253257
vercelManageAccessUrl?: string;
254258
currentTriggerVersion: string | null;
259+
currentTriggerVersionFetchFailed: boolean;
255260
}> => {
256261
if (!orgIntegration) {
257-
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null };
262+
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false };
258263
}
259264
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
260265
if (clientResult.isErr()) {
261-
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null };
266+
// We couldn't even build a Vercel client — treat as fetch failure so the UI
267+
// still prompts the user when they disable atomic deployments.
268+
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null, currentTriggerVersionFetchFailed: true };
262269
}
263270
const client = clientResult.value;
264271
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
@@ -279,7 +286,7 @@ export class VercelSettingsPresenter extends BasePresenter {
279286
}
280287

281288
if (!connectedProject) {
282-
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null };
289+
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null, currentTriggerVersionFetchFailed: false };
283290
}
284291

285292
const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([
@@ -303,13 +310,15 @@ export class VercelSettingsPresenter extends BasePresenter {
303310
]);
304311

305312
let currentTriggerVersion: string | null = null;
313+
let currentTriggerVersionFetchFailed = false;
306314
if (triggerVersionResult.isOk()) {
307315
const match = triggerVersionResult.value.find(
308316
(envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production")
309317
);
310318
currentTriggerVersion = match?.value ?? null;
311319
} else {
312-
logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — continuing without it", {
320+
currentTriggerVersionFetchFailed = true;
321+
logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — surfacing as unknown", {
313322
projectId,
314323
vercelProjectId: connectedProject.vercelProjectId,
315324
error: triggerVersionResult.error.message,
@@ -321,13 +330,14 @@ export class VercelSettingsPresenter extends BasePresenter {
321330
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
322331
vercelManageAccessUrl,
323332
currentTriggerVersion,
333+
currentTriggerVersionFetchFailed,
324334
};
325335
};
326336

327337
return fromPromise(
328338
fetchVercelData(),
329339
(error) => ({ type: "other" as const, cause: error })
330-
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion }) => ({
340+
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion, currentTriggerVersionFetchFailed }) => ({
331341
enabled: true,
332342
hasOrgIntegration,
333343
authInvalid: false,
@@ -339,6 +349,7 @@ export class VercelSettingsPresenter extends BasePresenter {
339349
autoAssignCustomDomains,
340350
vercelManageAccessUrl,
341351
currentTriggerVersion,
352+
currentTriggerVersionFetchFailed,
342353
} as VercelSettingsResult));
343354
}).mapErr((error) => {
344355
// Log the error and return a safe fallback

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,23 @@ function parseVercelStagingEnvironment(
9292
);
9393
}
9494

95+
// Sentinel values for the clearTriggerVersion hidden input. Used by the schema transform,
96+
// the input's defaultValue, and the modal's submit helper — keep all three reading the same
97+
// constants so they cannot drift.
98+
const CLEAR_TRIGGER_VERSION_YES = "true";
99+
const CLEAR_TRIGGER_VERSION_NO = "false";
100+
95101
const UpdateVercelConfigFormSchema = z.object({
96102
action: z.literal("update-config"),
97103
atomicBuilds: envSlugArrayField,
98104
pullEnvVarsBeforeBuild: envSlugArrayField,
99105
discoverEnvVars: envSlugArrayField,
100106
vercelStagingEnvironment: z.string().nullable().optional(),
101107
autoPromote: z.string().optional().transform((val) => val !== "false"),
102-
clearTriggerVersion: z.string().optional().transform((val) => val === "true"),
108+
clearTriggerVersion: z
109+
.string()
110+
.optional()
111+
.transform((val) => val === CLEAR_TRIGGER_VERSION_YES),
103112
});
104113

105114
const DisconnectVercelFormSchema = z.object({
@@ -275,8 +284,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
275284

276285
// When atomic deployments are being disabled and the user confirmed clearing the pin,
277286
// remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned.
287+
// If the Vercel API call fails we still consider the settings save itself successful,
288+
// but tell the user so they can clear the env var manually from the Vercel dashboard.
278289
if (clearTriggerVersion && !atomicBuilds?.includes("prod")) {
279-
await vercelService.clearTriggerVersionFromVercelProduction(project.id);
290+
const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id);
291+
if (!cleared) {
292+
return redirectWithErrorMessage(
293+
settingsPath,
294+
request,
295+
"Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings."
296+
);
297+
}
280298
}
281299

282300
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
@@ -582,6 +600,7 @@ function ConnectedVercelProjectForm({
582600
customEnvironments,
583601
autoAssignCustomDomains,
584602
currentTriggerVersion,
603+
currentTriggerVersionFetchFailed,
585604
organizationSlug,
586605
projectSlug,
587606
environmentSlug,
@@ -592,6 +611,7 @@ function ConnectedVercelProjectForm({
592611
customEnvironments: Array<{ id: string; slug: string }>;
593612
autoAssignCustomDomains: boolean | null;
594613
currentTriggerVersion: string | null;
614+
currentTriggerVersionFetchFailed: boolean;
595615
organizationSlug: string;
596616
projectSlug: string;
597617
environmentSlug: string;
@@ -661,14 +681,20 @@ function ConnectedVercelProjectForm({
661681

662682
// Modal trigger uses the page-load state of atomicBuilds, not whatever changed in-session,
663683
// because clearing TRIGGER_VERSION only makes sense when atomic was actually on at load time.
684+
// If the Vercel lookup failed we still prompt — we don't know whether a pin exists, so the
685+
// user needs to make the call explicitly rather than silently leaving prod pinned.
664686
const wasAtomicEnabledAtLoad = originalAtomicBuilds.includes("prod");
665687
const isAtomicNowDisabled = !configValues.atomicBuilds.includes("prod");
666688
const shouldPromptClearOnSave =
667-
wasAtomicEnabledAtLoad && isAtomicNowDisabled && Boolean(currentTriggerVersion);
689+
wasAtomicEnabledAtLoad &&
690+
isAtomicNowDisabled &&
691+
(Boolean(currentTriggerVersion) || currentTriggerVersionFetchFailed);
668692

669693
const submitWithClearChoice = (clear: boolean) => {
670694
if (clearTriggerVersionInputRef.current) {
671-
clearTriggerVersionInputRef.current.value = clear ? "true" : "false";
695+
clearTriggerVersionInputRef.current.value = clear
696+
? CLEAR_TRIGGER_VERSION_YES
697+
: CLEAR_TRIGGER_VERSION_NO;
672698
}
673699
setShowClearDialog(false);
674700
// Conform owns the form's React ref via {...configForm.props}, so look it up by id
@@ -774,11 +800,11 @@ function ConnectedVercelProjectForm({
774800
name="autoPromote"
775801
value={String(configValues.autoPromote)}
776802
/>
777-
{/* Toggled to "true" by the clear-pinned-version modal; defaults to "false". */}
803+
{/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */}
778804
<input
779805
type="hidden"
780806
name="clearTriggerVersion"
781-
defaultValue="false"
807+
defaultValue={CLEAR_TRIGGER_VERSION_NO}
782808
ref={clearTriggerVersionInputRef}
783809
/>
784810

@@ -859,6 +885,7 @@ function ConnectedVercelProjectForm({
859885
setConfigValues((prev) => ({ ...prev, autoPromote: value }))
860886
}
861887
currentTriggerVersion={currentTriggerVersion}
888+
currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed}
862889
hideSectionToggles
863890
/>
864891

@@ -927,12 +954,22 @@ function ConnectedVercelProjectForm({
927954
<DialogContent className="max-w-md">
928955
<DialogHeader>Clear TRIGGER_VERSION from Vercel?</DialogHeader>
929956
<div className="flex flex-col gap-3 pt-3">
930-
<Paragraph className="mb-1">
931-
Atomic deployments are being turned off. The{" "}
932-
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> env var on your
933-
Vercel production environment is currently set to{" "}
934-
<span className="font-mono text-text-bright">{currentTriggerVersion}</span>.
935-
</Paragraph>
957+
{currentTriggerVersion ? (
958+
<Paragraph className="mb-1">
959+
Atomic deployments are being turned off. The{" "}
960+
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> env var on
961+
your Vercel production environment is currently set to{" "}
962+
<span className="font-mono text-text-bright">{currentTriggerVersion}</span>.
963+
</Paragraph>
964+
) : (
965+
<Paragraph className="mb-1">
966+
Atomic deployments are being turned off. We couldn't reach Vercel to confirm
967+
whether{" "}
968+
<span className="font-mono text-text-bright">TRIGGER_VERSION</span> is currently
969+
set on your Vercel production environment, so please verify in the Vercel
970+
dashboard.
971+
</Paragraph>
972+
)}
936973
<Paragraph className="mb-1">
937974
If you leave it, your Vercel project will stay pinned to this version. Since atomic
938975
deployments will be off, Trigger.dev will no longer update this variable, and future
@@ -1038,6 +1075,7 @@ function VercelSettingsPanel({
10381075
customEnvironments={data.customEnvironments}
10391076
autoAssignCustomDomains={data.autoAssignCustomDomains ?? null}
10401077
currentTriggerVersion={data.currentTriggerVersion ?? null}
1078+
currentTriggerVersionFetchFailed={data.currentTriggerVersionFetchFailed ?? false}
10411079
organizationSlug={organizationSlug}
10421080
projectSlug={projectSlug}
10431081
environmentSlug={environmentSlug}

0 commit comments

Comments
 (0)