Skip to content

Commit 4685e31

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

6 files changed

Lines changed: 243 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Show the currently pinned `TRIGGER_VERSION` under the Atomic deployments toggle on the Vercel
7+
integration settings, and prompt the user to clear it from Vercel production when they disable
8+
atomic deployments. Also mark `TRIGGER_SECRET_KEY` writes to Vercel as `sensitive` so the value
9+
cannot be read back from the Vercel dashboard or API once written.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ type BuildSettingsFieldsProps = {
2323
disabledEnvSlugs?: Partial<Record<EnvSlug, string>>;
2424
autoPromote?: boolean;
2525
onAutoPromoteChange?: (value: boolean) => void;
26+
/** The currently pinned TRIGGER_VERSION on Vercel production, if any. Shown under the
27+
* Atomic deployments toggle so the user knows what version is set on Vercel right now. */
28+
currentTriggerVersion?: string | null;
2629
/** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */
2730
hideSectionToggles?: boolean;
2831
};
@@ -39,6 +42,7 @@ export function BuildSettingsFields({
3942
disabledEnvSlugs,
4043
autoPromote,
4144
onAutoPromoteChange,
45+
currentTriggerVersion,
4246
hideSectionToggles,
4347
}: BuildSettingsFieldsProps) {
4448
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
@@ -208,6 +212,13 @@ export function BuildSettingsFields({
208212
</TextLink>
209213
.
210214
</Hint>
215+
{currentTriggerVersion && (
216+
<Hint className="pr-6">
217+
Currently pinned to{" "}
218+
<span className="font-mono text-text-bright">{currentTriggerVersion}</span> in Vercel
219+
production.
220+
</Hint>
221+
)}
211222
</div>
212223

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

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,7 @@ export class VercelIntegrationRepository {
960960
key: "TRIGGER_SECRET_KEY",
961961
value: runtimeEnv.apiKey,
962962
target: vercelTarget,
963-
type: "encrypted",
963+
type: "sensitive",
964964
environmentType: runtimeEnv.type,
965965
});
966966
}
@@ -1054,14 +1054,18 @@ 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.
10571061
await this.upsertVercelEnvVar({
10581062
client,
10591063
vercelProjectId: projectIntegration.externalEntityId,
10601064
teamId,
10611065
key: "TRIGGER_SECRET_KEY",
10621066
value: params.apiKey,
10631067
target: vercelTarget,
1064-
type: "encrypted",
1068+
type: "sensitive",
10651069
});
10661070

10671071
logger.info("Synced regenerated API key to Vercel", {

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export type VercelSettingsResult = {
4242
autoAssignCustomDomains?: boolean | null;
4343
/** URL to manage Vercel integration access (project sharing) on vercel.com */
4444
vercelManageAccessUrl?: string;
45+
/** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface
46+
* the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */
47+
currentTriggerVersion?: string | null;
4548
};
4649

4750
export type VercelAvailableProject = {
@@ -248,13 +251,14 @@ export class VercelSettingsPresenter extends BasePresenter {
248251
customEnvironments: VercelCustomEnvironment[];
249252
autoAssignCustomDomains: boolean | null;
250253
vercelManageAccessUrl?: string;
254+
currentTriggerVersion: string | null;
251255
}> => {
252256
if (!orgIntegration) {
253-
return { customEnvironments: [], autoAssignCustomDomains: null };
257+
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null };
254258
}
255259
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
256260
if (clientResult.isErr()) {
257-
return { customEnvironments: [], autoAssignCustomDomains: null };
261+
return { customEnvironments: [], autoAssignCustomDomains: null, currentTriggerVersion: null };
258262
}
259263
const client = clientResult.value;
260264
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
@@ -275,10 +279,10 @@ export class VercelSettingsPresenter extends BasePresenter {
275279
}
276280

277281
if (!connectedProject) {
278-
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl };
282+
return { customEnvironments: [], autoAssignCustomDomains: null, vercelManageAccessUrl, currentTriggerVersion: null };
279283
}
280284

281-
const [customEnvsResult, autoAssignResult] = await Promise.all([
285+
const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([
282286
VercelIntegrationRepository.getVercelCustomEnvironments(
283287
client,
284288
connectedProject.vercelProjectId,
@@ -289,18 +293,41 @@ export class VercelSettingsPresenter extends BasePresenter {
289293
connectedProject.vercelProjectId,
290294
teamId
291295
),
296+
VercelIntegrationRepository.getVercelEnvironmentVariableValues(
297+
client,
298+
connectedProject.vercelProjectId,
299+
teamId,
300+
"production",
301+
(key) => key === "TRIGGER_VERSION"
302+
),
292303
]);
304+
305+
let currentTriggerVersion: string | null = null;
306+
if (triggerVersionResult.isOk()) {
307+
const match = triggerVersionResult.value.find(
308+
(envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production")
309+
);
310+
currentTriggerVersion = match?.value ?? null;
311+
} else {
312+
logger.warn("Failed to fetch current TRIGGER_VERSION from Vercel — continuing without it", {
313+
projectId,
314+
vercelProjectId: connectedProject.vercelProjectId,
315+
error: triggerVersionResult.error.message,
316+
});
317+
}
318+
293319
return {
294320
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
295321
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
296322
vercelManageAccessUrl,
323+
currentTriggerVersion,
297324
};
298325
};
299326

300327
return fromPromise(
301328
fetchVercelData(),
302329
(error) => ({ type: "other" as const, cause: error })
303-
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl }) => ({
330+
).map(({ customEnvironments, autoAssignCustomDomains, vercelManageAccessUrl, currentTriggerVersion }) => ({
304331
enabled: true,
305332
hasOrgIntegration,
306333
authInvalid: false,
@@ -311,6 +338,7 @@ export class VercelSettingsPresenter extends BasePresenter {
311338
customEnvironments,
312339
autoAssignCustomDomains,
313340
vercelManageAccessUrl,
341+
currentTriggerVersion,
314342
} as VercelSettingsResult));
315343
}).mapErr((error) => {
316344
// Log the error and return a safe fallback

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import {
6161
getAvailableEnvSlugsForBuildSettings,
6262
} from "~/v3/vercel/vercelProjectIntegrationSchema";
6363
import { Result, fromPromise } from "neverthrow";
64-
import { useEffect, useState } from "react";
64+
import { useEffect, useRef, useState } from "react";
6565

6666
export type ConnectedVercelProject = {
6767
id: string;
@@ -99,6 +99,7 @@ const UpdateVercelConfigFormSchema = z.object({
9999
discoverEnvVars: envSlugArrayField,
100100
vercelStagingEnvironment: z.string().nullable().optional(),
101101
autoPromote: z.string().optional().transform((val) => val !== "false"),
102+
clearTriggerVersion: z.string().optional().transform((val) => val === "true"),
102103
});
103104

104105
const DisconnectVercelFormSchema = z.object({
@@ -243,6 +244,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
243244
discoverEnvVars,
244245
vercelStagingEnvironment,
245246
autoPromote,
247+
clearTriggerVersion,
246248
} = submission.value;
247249

248250
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
@@ -271,6 +273,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
271273
);
272274
}
273275

276+
// When atomic deployments are being disabled and the user confirmed clearing the pin,
277+
// remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned.
278+
if (clearTriggerVersion && !atomicBuilds?.includes("prod")) {
279+
await vercelService.clearTriggerVersionFromVercelProduction(project.id);
280+
}
281+
274282
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
275283
}
276284

@@ -573,6 +581,7 @@ function ConnectedVercelProjectForm({
573581
hasPreviewEnvironment,
574582
customEnvironments,
575583
autoAssignCustomDomains,
584+
currentTriggerVersion,
576585
organizationSlug,
577586
projectSlug,
578587
environmentSlug,
@@ -582,6 +591,7 @@ function ConnectedVercelProjectForm({
582591
hasPreviewEnvironment: boolean;
583592
customEnvironments: Array<{ id: string; slug: string }>;
584593
autoAssignCustomDomains: boolean | null;
594+
currentTriggerVersion: string | null;
585595
organizationSlug: string;
586596
projectSlug: string;
587597
environmentSlug: string;
@@ -645,6 +655,28 @@ function ConnectedVercelProjectForm({
645655
},
646656
});
647657

658+
const saveButtonRef = useRef<HTMLButtonElement>(null);
659+
const clearTriggerVersionInputRef = useRef<HTMLInputElement>(null);
660+
const [showClearDialog, setShowClearDialog] = useState(false);
661+
662+
// Modal trigger uses the page-load state of atomicBuilds, not whatever changed in-session,
663+
// because clearing TRIGGER_VERSION only makes sense when atomic was actually on at load time.
664+
const wasAtomicEnabledAtLoad = originalAtomicBuilds.includes("prod");
665+
const isAtomicNowDisabled = !configValues.atomicBuilds.includes("prod");
666+
const shouldPromptClearOnSave =
667+
wasAtomicEnabledAtLoad && isAtomicNowDisabled && Boolean(currentTriggerVersion);
668+
669+
const submitWithClearChoice = (clear: boolean) => {
670+
if (clearTriggerVersionInputRef.current) {
671+
clearTriggerVersionInputRef.current.value = clear ? "true" : "false";
672+
}
673+
setShowClearDialog(false);
674+
// Conform owns the form's React ref via {...configForm.props}, so look it up by id
675+
// (set via useForm({ id: "update-vercel-config" })) rather than fighting for the ref.
676+
const form = document.getElementById("update-vercel-config") as HTMLFormElement | null;
677+
form?.requestSubmit(saveButtonRef.current ?? undefined);
678+
};
679+
648680
const isConfigLoading =
649681
navigation.formData?.get("action") === "update-config" &&
650682
(navigation.state === "submitting" || navigation.state === "loading");
@@ -742,6 +774,13 @@ function ConnectedVercelProjectForm({
742774
name="autoPromote"
743775
value={String(configValues.autoPromote)}
744776
/>
777+
{/* Toggled to "true" by the clear-pinned-version modal; defaults to "false". */}
778+
<input
779+
type="hidden"
780+
name="clearTriggerVersion"
781+
defaultValue="false"
782+
ref={clearTriggerVersionInputRef}
783+
/>
745784

746785
<Fieldset>
747786
<InputGroup fullWidth>
@@ -819,6 +858,7 @@ function ConnectedVercelProjectForm({
819858
onAutoPromoteChange={(value) =>
820859
setConfigValues((prev) => ({ ...prev, autoPromote: value }))
821860
}
861+
currentTriggerVersion={currentTriggerVersion}
822862
hideSectionToggles
823863
/>
824864

@@ -862,19 +902,68 @@ function ConnectedVercelProjectForm({
862902
<FormButtons
863903
confirmButton={
864904
<Button
905+
ref={saveButtonRef}
865906
type="submit"
866907
name="action"
867908
value="update-config"
868909
variant="secondary/small"
869910
disabled={isConfigLoading || !hasConfigChanges}
870911
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
912+
onClick={(event) => {
913+
if (shouldPromptClearOnSave) {
914+
event.preventDefault();
915+
setShowClearDialog(true);
916+
}
917+
}}
871918
>
872919
Save
873920
</Button>
874921
}
875922
/>
876923
</Fieldset>
877924
</Form>
925+
926+
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
927+
<DialogContent className="max-w-md">
928+
<DialogHeader>Clear TRIGGER_VERSION from Vercel?</DialogHeader>
929+
<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>
936+
<Paragraph className="mb-1">
937+
If you leave it, your Vercel project will stay pinned to this version. Since atomic
938+
deployments will be off, Trigger.dev will no longer update this variable, and future
939+
Vercel deploys will continue using this pinned version. We recommend clearing it.
940+
</Paragraph>
941+
<FormButtons
942+
confirmButton={
943+
<div className="flex gap-2">
944+
<Button
945+
variant="secondary/medium"
946+
onClick={() => submitWithClearChoice(false)}
947+
>
948+
Keep pinned
949+
</Button>
950+
<Button
951+
variant="primary/medium"
952+
onClick={() => submitWithClearChoice(true)}
953+
>
954+
Clear and disable
955+
</Button>
956+
</div>
957+
}
958+
cancelButton={
959+
<DialogClose asChild>
960+
<Button variant="tertiary/medium">Cancel</Button>
961+
</DialogClose>
962+
}
963+
/>
964+
</div>
965+
</DialogContent>
966+
</Dialog>
878967
</>
879968
);
880969
}
@@ -948,6 +1037,7 @@ function VercelSettingsPanel({
9481037
hasPreviewEnvironment={data.hasPreviewEnvironment}
9491038
customEnvironments={data.customEnvironments}
9501039
autoAssignCustomDomains={data.autoAssignCustomDomains ?? null}
1040+
currentTriggerVersion={data.currentTriggerVersion ?? null}
9511041
organizationSlug={organizationSlug}
9521042
projectSlug={projectSlug}
9531043
environmentSlug={environmentSlug}

0 commit comments

Comments
 (0)