Skip to content

Commit f384e83

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
feat(webapp,cli): support empty-string environment variable values
Adds support for environment variables with empty-string values, rolling out gradually. Once enabled for your organization, an empty value stays stored and available to tasks, including through environment-variable imports and Vercel sync. Deleting a variable remains a separate action. Also fixes local dev environment precedence so freshly resolved project values reach task processes correctly. Mono-RevId: 25dea78a2c72a984a9bd9ac432c11026f220576c
1 parent 2d03fee commit f384e83

32 files changed

Lines changed: 1665 additions & 187 deletions

.changeset/empty-dev-env-vars.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Fix stale and empty project environment values in `trigger dev`, and support empty values in `syncEnvVars()`.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Support empty-string environment values across the dashboard, API, and Vercel sync behind a feature flag, disabled by default.

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

Lines changed: 66 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import { emptyEnvironmentVariableValuesEnabledForProject } from "~/v3/environmentVariables/emptyValuesFlag.server";
2+
import {
3+
normalizeTarget,
4+
isVercelSecretType,
5+
toVercelEnvironmentVariableValue,
6+
resolveVercelSharedValue,
7+
mergeVercelEnvironmentVariableValues,
8+
} from "~/v3/vercel/environmentVariableValues";
19
import pLimit from "p-limit";
210
import { Vercel } from "@vercel/sdk";
311
import type {
@@ -38,12 +46,6 @@ import {
3846
// Pure helpers
3947
// ---------------------------------------------------------------------------
4048

41-
function normalizeTarget(target: string[] | string | undefined): string[] {
42-
if (Array.isArray(target)) return target.filter(Boolean);
43-
if (typeof target === "string") return [target];
44-
return [];
45-
}
46-
4749
function readProjectEnvs(
4850
response: unknown,
4951
logContext: Record<string, unknown>
@@ -86,10 +88,6 @@ function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target:
8688
});
8789
}
8890

89-
function isVercelSecretType(type: string): boolean {
90-
return type === "secret" || type === "sensitive";
91-
}
92-
9391
export type CreateEnvVarsIfAbsentResult = {
9492
written: string[];
9593
skipped: string[];
@@ -226,19 +224,6 @@ function toVercelCustomEnvironment({
226224
return { id, slug, description, branchMatcher };
227225
}
228226

229-
function toVercelEnvironmentVariableValue(
230-
env: ResponseBodyEnvs
231-
): VercelEnvironmentVariableValue | null {
232-
if (!env.value) return null;
233-
return {
234-
key: env.key,
235-
value: env.value,
236-
target: normalizeTarget(env.target),
237-
type: env.type,
238-
isSecret: isVercelSecretType(env.type),
239-
};
240-
}
241-
242227
// ---------------------------------------------------------------------------
243228
// Repository
244229
// ---------------------------------------------------------------------------
@@ -474,7 +459,8 @@ export class VercelIntegrationRepository {
474459
teamId?: string | null,
475460
target?: string,
476461
/** If provided, only include keys that pass this filter */
477-
shouldIncludeKey?: (key: string) => boolean
462+
shouldIncludeKey?: (key: string) => boolean,
463+
allowEmptyValues = true
478464
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
479465
return wrapVercelCallWithRecovery(
480466
client.projects.filterProjectEnvs({
@@ -499,7 +485,9 @@ export class VercelIntegrationRepository {
499485
return ResultAsync.fromPromise(
500486
Promise.all(
501487
filteredEnvs.map((env) =>
502-
concurrencyLimit(() => this.#resolveEnvVarValue(client, projectId, teamId, env))
488+
concurrencyLimit(() =>
489+
this.#resolveEnvVarValue(client, projectId, teamId, env, allowEmptyValues)
490+
)
503491
)
504492
),
505493
(error) => toVercelApiError(error)
@@ -511,12 +499,13 @@ export class VercelIntegrationRepository {
511499
client: Vercel,
512500
projectId: string,
513501
teamId: string | null | undefined,
514-
env: ResponseBodyEnvs
502+
env: ResponseBodyEnvs,
503+
allowEmptyValues: boolean
515504
): Promise<VercelEnvironmentVariableValue | null> {
516505
// Non-encrypted vars: use value from list response if present
517506
if (env.type !== "encrypted" || !env.id) {
518507
if (env.value === undefined || env.value === null) return null;
519-
return toVercelEnvironmentVariableValue(env);
508+
return toVercelEnvironmentVariableValue(env, allowEmptyValues);
520509
}
521510

522511
// Encrypted vars: fetch decrypted value via individual endpoint
@@ -545,7 +534,8 @@ export class VercelIntegrationRepository {
545534

546535
// API returns union: ResponseBody1 has no value, ResponseBody2/3 have value
547536
const decryptedValue = (result.value as { value?: string }).value;
548-
if (typeof decryptedValue !== "string") return null;
537+
if (typeof decryptedValue !== "string" || (!allowEmptyValues && decryptedValue.trim() === ""))
538+
return null;
549539

550540
return {
551541
key: env.key,
@@ -653,7 +643,8 @@ export class VercelIntegrationRepository {
653643
client: Vercel,
654644
accessToken: string,
655645
teamId: string,
656-
projectId?: string // Optional: filter by project
646+
projectId?: string,
647+
allowEmptyValues = true
657648
): ResultAsync<
658649
Array<{
659650
key: string;
@@ -684,53 +675,43 @@ export class VercelIntegrationRepository {
684675

685676
if (isSecret) return null;
686677

687-
const listValue = env.value;
688-
const applyToAllCustomEnvs = env.applyToAllCustomEnvironments;
689-
690-
if (listValue) {
691-
return {
692-
key: envKey,
693-
value: listValue,
694-
target: normalizeTarget(env.target),
695-
type,
696-
isSecret,
697-
applyToAllCustomEnvironments: applyToAllCustomEnvs,
698-
};
699-
}
700-
701-
// Try to get the decrypted value for this shared env var
702-
const getResult = await callVercelWithRecovery(
703-
client.environment.getSharedEnvVar({
704-
id: envId,
705-
teamId,
706-
}),
707-
VercelSchemas.getSharedEnvVar,
708-
{ context: "getSharedEnvVar" }
709-
);
678+
const value = await resolveVercelSharedValue(
679+
env.value,
680+
async () => {
681+
const getResult = await callVercelWithRecovery(
682+
client.environment.getSharedEnvVar({ id: envId, teamId }),
683+
VercelSchemas.getSharedEnvVar,
684+
{ context: "getSharedEnvVar" }
685+
);
710686

711-
if (getResult.isOk()) {
712-
if (!getResult.value.value) return null;
713-
return {
714-
key: envKey,
715-
value: getResult.value.value,
716-
target: normalizeTarget(env.target),
717-
type,
718-
isSecret,
719-
applyToAllCustomEnvironments: applyToAllCustomEnvs,
720-
};
721-
}
722-
723-
logger.warn("Failed to get decrypted value for shared env var", {
724-
teamId,
725-
projectId,
726-
envId,
727-
envKey,
728-
error: getResult.error.message,
729-
errorType: getResult.error.errorType,
730-
status: getResult.error.status,
731-
authInvalid: getResult.error.authInvalid,
732-
});
733-
return null;
687+
if (getResult.isOk()) {
688+
return getResult.value.value ?? null;
689+
}
690+
691+
logger.warn("Failed to get decrypted value for shared env var", {
692+
teamId,
693+
projectId,
694+
envId,
695+
envKey,
696+
error: getResult.error.message,
697+
errorType: getResult.error.errorType,
698+
status: getResult.error.status,
699+
authInvalid: getResult.error.authInvalid,
700+
});
701+
return null;
702+
},
703+
allowEmptyValues
704+
);
705+
if (value === null) return null;
706+
707+
return {
708+
key: envKey,
709+
value,
710+
target: normalizeTarget(env.target),
711+
type,
712+
isSecret,
713+
applyToAllCustomEnvironments: env.applyToAllCustomEnvironments,
714+
};
734715
})
735716
)
736717
),
@@ -1334,6 +1315,9 @@ export class VercelIntegrationRepository {
13341315
}
13351316

13361317
const envVarRepository = new EnvironmentVariablesRepository();
1318+
const allowEmptyValues = await emptyEnvironmentVariableValuesEnabledForProject(
1319+
params.projectId
1320+
);
13371321

13381322
// Fetch shared env vars once (they apply across all targets)
13391323
let sharedEnvVars: Array<{
@@ -1350,7 +1334,8 @@ export class VercelIntegrationRepository {
13501334
client,
13511335
accessToken,
13521336
params.teamId,
1353-
params.vercelProjectId
1337+
params.vercelProjectId,
1338+
allowEmptyValues
13541339
);
13551340
sharedEnvVars = sharedResult.unwrapOr([]);
13561341
}
@@ -1373,7 +1358,8 @@ export class VercelIntegrationRepository {
13731358
params.vercelProjectId,
13741359
params.teamId,
13751360
mapping.vercelTarget,
1376-
shouldIncludeKey
1361+
shouldIncludeKey,
1362+
allowEmptyValues
13771363
);
13781364

13791365
if (envVarsResult.isErr()) {
@@ -1399,11 +1385,10 @@ export class VercelIntegrationRepository {
13991385
return matchesTarget || matchesCustomEnv;
14001386
});
14011387

1402-
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
1403-
const sharedEnvVarsToAdd = filteredSharedEnvVars.filter(
1404-
(v) => !projectEnvVarKeys.has(v.key)
1388+
const mergedEnvVars = mergeVercelEnvironmentVariableValues(
1389+
projectEnvVars,
1390+
filteredSharedEnvVars
14051391
);
1406-
const mergedEnvVars = [...projectEnvVars, ...sharedEnvVarsToAdd];
14071392

14081393
if (mergedEnvVars.length === 0) {
14091394
return;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { emptyEnvironmentVariableValuesEnabled } from "~/v3/environmentVariables/emptyValuesFlag.server";
12
import type { PrismaClient, PrismaReplicaClient } from "~/db.server";
23
import { $replica, prisma } from "~/db.server";
34
import type { Project } from "~/models/project.server";
@@ -39,6 +40,7 @@ export class EnvironmentVariablesPresenter {
3940
const project = await this.#replicaClient.project.findFirst({
4041
select: {
4142
id: true,
43+
organization: { select: { featureFlags: true } },
4244
},
4345
where: {
4446
slug: projectSlug,
@@ -176,6 +178,10 @@ export class EnvironmentVariablesPresenter {
176178
}
177179

178180
return {
181+
allowEmptyEnvironmentVariableValues: await emptyEnvironmentVariableValuesEnabled(
182+
project.organization.featureFlags,
183+
this.#prismaClient
184+
),
179185
environmentVariables: environmentVariables.flatMap((environmentVariable) => {
180186
return sortedEnvironments.flatMap((env) => {
181187
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react";
2-
import { parseWithZod } from "@conform-to/zod/v4";
2+
import {
3+
emptyEnvironmentVariableValuesEnabled,
4+
EMPTY_ENV_VALUES_DISABLED,
5+
} from "~/v3/environmentVariables/emptyValuesFlag.server";
6+
import { parseEnvironmentVariableForm } from "~/v3/environmentVariables/forms";
37
import {
48
LockClosedIcon,
59
LockOpenIcon,
@@ -54,15 +58,18 @@ import {
5458
v3EnvironmentVariablesPath,
5559
} from "~/utils/pathBuilder";
5660
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
57-
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
61+
import {
62+
EnvironmentVariableKey,
63+
EnvironmentVariableValue,
64+
} from "~/v3/environmentVariables/repository";
5865
import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments";
5966
import { pageMeta } from "~/utils/pageTitle";
6067

6168
export const meta = pageMeta("New environment variable");
6269

6370
const Variable = z.object({
6471
key: EnvironmentVariableKey,
65-
value: z.string().nonempty("Value is required"),
72+
value: EnvironmentVariableValue,
6673
});
6774

6875
type Variable = z.infer<typeof Variable>;
@@ -121,7 +128,7 @@ export const action = dashboardAction(
121128
}
122129

123130
const formData = await request.formData();
124-
const submission = parseWithZod(formData, { schema });
131+
const submission = parseEnvironmentVariableForm(formData, schema);
125132

126133
if (submission.status !== "success") {
127134
return json(submission.reply());
@@ -162,6 +169,7 @@ export const action = dashboardAction(
162169
},
163170
select: {
164171
id: true,
172+
organization: { select: { featureFlags: true } },
165173
},
166174
});
167175
if (!project) {
@@ -193,6 +201,13 @@ export const action = dashboardAction(
193201
);
194202
}
195203

204+
if (
205+
submission.value.variables.some((v) => v.value.trim() === "") &&
206+
!(await emptyEnvironmentVariableValuesEnabled(project.organization.featureFlags))
207+
) {
208+
return json(submission.reply({ formErrors: [EMPTY_ENV_VALUES_DISABLED] }));
209+
}
210+
196211
const repository = new EnvironmentVariablesRepository(prisma);
197212
const result = await repository.create(project.id, {
198213
...submission.value,
@@ -238,7 +253,8 @@ export default function Page() {
238253
parentData,
239254
"Environment variables page loader data must be defined when rendering the create dialog"
240255
);
241-
const { environments, hasStaging, writableEnvironmentIds } = parentData;
256+
const { environments, hasStaging, writableEnvironmentIds, allowEmptyEnvironmentVariableValues } =
257+
parentData;
242258
// Creating a variable is a write, so gate the targets on write access.
243259
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
244260
const lastSubmission = useActionData();
@@ -266,7 +282,7 @@ export default function Page() {
266282
// TODO: type this
267283
lastResult: lastSubmission as any,
268284
onValidate({ formData }) {
269-
return parseWithZod(formData, { schema });
285+
return parseEnvironmentVariableForm(formData, schema, allowEmptyEnvironmentVariableValues);
270286
},
271287
shouldRevalidate: "onSubmit",
272288
defaultValue: {

0 commit comments

Comments
 (0)