From f384e8334a8eca3266ecb33e5fae7d1fbf148d59 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:41:09 +0000 Subject: [PATCH 1/6] 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 --- .changeset/empty-dev-env-vars.md | 6 + .server-changes/envvar-empty-value.md | 6 + .../app/models/vercelIntegration.server.ts | 147 +++-- .../EnvironmentVariablesPresenter.server.ts | 6 + .../route.tsx | 28 +- .../route.tsx | 63 ++- ...rojects.$projectRef.envvars.$slug.$name.ts | 1 - .../api.v1.projects.$projectRef.envvars.ts | 5 + .../emptyEnvironmentValuesRollout.test.ts | 293 ++++++++++ .../emptyValuesFlag.server.ts | 34 ++ .../environmentVariablesRepository.server.ts | 130 +++-- .../app/v3/environmentVariables/forms.ts | 29 + .../app/v3/environmentVariables/repository.ts | 8 +- apps/webapp/app/v3/featureFlags.ts | 2 + .../v3/vercel/environmentVariableValues.ts | 57 ++ .../test/environmentVariablesForms.test.ts | 110 ++++ .../environmentVariablesRepository.test.ts | 506 +++++++++++++++++- .../vercelEnvironmentVariableValues.test.ts | 77 +++ docs/deploy-environment-variables.mdx | 6 + docs/v3-openapi.yaml | 1 + packages/cli-v3/src/build/buildWorker.ts | 2 + packages/cli-v3/src/build/extensions.test.ts | 95 ++++ packages/cli-v3/src/build/extensions.ts | 27 +- packages/cli-v3/src/commands/deploy.ts | 4 + packages/cli-v3/src/dev/backgroundWorker.ts | 2 + packages/cli-v3/src/dev/devSupervisor.ts | 38 +- .../src/entryPoints/dev-run-controller.ts | 17 +- .../cli-v3/src/utilities/localEnvVars.test.ts | 64 +++ packages/cli-v3/src/utilities/localEnvVars.ts | 25 +- .../src/utilities/sanitizeEnvVars.test.ts | 39 ++ .../cli-v3/src/utilities/sanitizeEnvVars.ts | 23 + packages/core/src/v3/schemas/api.ts | 1 + 32 files changed, 1665 insertions(+), 187 deletions(-) create mode 100644 .changeset/empty-dev-env-vars.md create mode 100644 .server-changes/envvar-empty-value.md create mode 100644 apps/webapp/app/v3/environmentVariables/emptyEnvironmentValuesRollout.test.ts create mode 100644 apps/webapp/app/v3/environmentVariables/emptyValuesFlag.server.ts create mode 100644 apps/webapp/app/v3/environmentVariables/forms.ts create mode 100644 apps/webapp/app/v3/vercel/environmentVariableValues.ts create mode 100644 apps/webapp/test/environmentVariablesForms.test.ts create mode 100644 apps/webapp/test/vercelEnvironmentVariableValues.test.ts create mode 100644 packages/cli-v3/src/build/extensions.test.ts create mode 100644 packages/cli-v3/src/utilities/localEnvVars.test.ts create mode 100644 packages/cli-v3/src/utilities/sanitizeEnvVars.test.ts diff --git a/.changeset/empty-dev-env-vars.md b/.changeset/empty-dev-env-vars.md new file mode 100644 index 00000000000..e28169a97e3 --- /dev/null +++ b/.changeset/empty-dev-env-vars.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Fix stale and empty project environment values in `trigger dev`, and support empty values in `syncEnvVars()`. diff --git a/.server-changes/envvar-empty-value.md b/.server-changes/envvar-empty-value.md new file mode 100644 index 00000000000..892de94c810 --- /dev/null +++ b/.server-changes/envvar-empty-value.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Support empty-string environment values across the dashboard, API, and Vercel sync behind a feature flag, disabled by default. diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 277a985678b..2e95576bd2b 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1,3 +1,11 @@ +import { emptyEnvironmentVariableValuesEnabledForProject } from "~/v3/environmentVariables/emptyValuesFlag.server"; +import { + normalizeTarget, + isVercelSecretType, + toVercelEnvironmentVariableValue, + resolveVercelSharedValue, + mergeVercelEnvironmentVariableValues, +} from "~/v3/vercel/environmentVariableValues"; import pLimit from "p-limit"; import { Vercel } from "@vercel/sdk"; import type { @@ -38,12 +46,6 @@ import { // Pure helpers // --------------------------------------------------------------------------- -function normalizeTarget(target: string[] | string | undefined): string[] { - if (Array.isArray(target)) return target.filter(Boolean); - if (typeof target === "string") return [target]; - return []; -} - function readProjectEnvs( response: unknown, logContext: Record @@ -86,10 +88,6 @@ function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target: }); } -function isVercelSecretType(type: string): boolean { - return type === "secret" || type === "sensitive"; -} - export type CreateEnvVarsIfAbsentResult = { written: string[]; skipped: string[]; @@ -226,19 +224,6 @@ function toVercelCustomEnvironment({ return { id, slug, description, branchMatcher }; } -function toVercelEnvironmentVariableValue( - env: ResponseBodyEnvs -): VercelEnvironmentVariableValue | null { - if (!env.value) return null; - return { - key: env.key, - value: env.value, - target: normalizeTarget(env.target), - type: env.type, - isSecret: isVercelSecretType(env.type), - }; -} - // --------------------------------------------------------------------------- // Repository // --------------------------------------------------------------------------- @@ -474,7 +459,8 @@ export class VercelIntegrationRepository { teamId?: string | null, target?: string, /** If provided, only include keys that pass this filter */ - shouldIncludeKey?: (key: string) => boolean + shouldIncludeKey?: (key: string) => boolean, + allowEmptyValues = true ): ResultAsync { return wrapVercelCallWithRecovery( client.projects.filterProjectEnvs({ @@ -499,7 +485,9 @@ export class VercelIntegrationRepository { return ResultAsync.fromPromise( Promise.all( filteredEnvs.map((env) => - concurrencyLimit(() => this.#resolveEnvVarValue(client, projectId, teamId, env)) + concurrencyLimit(() => + this.#resolveEnvVarValue(client, projectId, teamId, env, allowEmptyValues) + ) ) ), (error) => toVercelApiError(error) @@ -511,12 +499,13 @@ export class VercelIntegrationRepository { client: Vercel, projectId: string, teamId: string | null | undefined, - env: ResponseBodyEnvs + env: ResponseBodyEnvs, + allowEmptyValues: boolean ): Promise { // Non-encrypted vars: use value from list response if present if (env.type !== "encrypted" || !env.id) { if (env.value === undefined || env.value === null) return null; - return toVercelEnvironmentVariableValue(env); + return toVercelEnvironmentVariableValue(env, allowEmptyValues); } // Encrypted vars: fetch decrypted value via individual endpoint @@ -545,7 +534,8 @@ export class VercelIntegrationRepository { // API returns union: ResponseBody1 has no value, ResponseBody2/3 have value const decryptedValue = (result.value as { value?: string }).value; - if (typeof decryptedValue !== "string") return null; + if (typeof decryptedValue !== "string" || (!allowEmptyValues && decryptedValue.trim() === "")) + return null; return { key: env.key, @@ -653,7 +643,8 @@ export class VercelIntegrationRepository { client: Vercel, accessToken: string, teamId: string, - projectId?: string // Optional: filter by project + projectId?: string, + allowEmptyValues = true ): ResultAsync< Array<{ key: string; @@ -684,53 +675,43 @@ export class VercelIntegrationRepository { if (isSecret) return null; - const listValue = env.value; - const applyToAllCustomEnvs = env.applyToAllCustomEnvironments; - - if (listValue) { - return { - key: envKey, - value: listValue, - target: normalizeTarget(env.target), - type, - isSecret, - applyToAllCustomEnvironments: applyToAllCustomEnvs, - }; - } - - // Try to get the decrypted value for this shared env var - const getResult = await callVercelWithRecovery( - client.environment.getSharedEnvVar({ - id: envId, - teamId, - }), - VercelSchemas.getSharedEnvVar, - { context: "getSharedEnvVar" } - ); + const value = await resolveVercelSharedValue( + env.value, + async () => { + const getResult = await callVercelWithRecovery( + client.environment.getSharedEnvVar({ id: envId, teamId }), + VercelSchemas.getSharedEnvVar, + { context: "getSharedEnvVar" } + ); - if (getResult.isOk()) { - if (!getResult.value.value) return null; - return { - key: envKey, - value: getResult.value.value, - target: normalizeTarget(env.target), - type, - isSecret, - applyToAllCustomEnvironments: applyToAllCustomEnvs, - }; - } - - logger.warn("Failed to get decrypted value for shared env var", { - teamId, - projectId, - envId, - envKey, - error: getResult.error.message, - errorType: getResult.error.errorType, - status: getResult.error.status, - authInvalid: getResult.error.authInvalid, - }); - return null; + if (getResult.isOk()) { + return getResult.value.value ?? null; + } + + logger.warn("Failed to get decrypted value for shared env var", { + teamId, + projectId, + envId, + envKey, + error: getResult.error.message, + errorType: getResult.error.errorType, + status: getResult.error.status, + authInvalid: getResult.error.authInvalid, + }); + return null; + }, + allowEmptyValues + ); + if (value === null) return null; + + return { + key: envKey, + value, + target: normalizeTarget(env.target), + type, + isSecret, + applyToAllCustomEnvironments: env.applyToAllCustomEnvironments, + }; }) ) ), @@ -1334,6 +1315,9 @@ export class VercelIntegrationRepository { } const envVarRepository = new EnvironmentVariablesRepository(); + const allowEmptyValues = await emptyEnvironmentVariableValuesEnabledForProject( + params.projectId + ); // Fetch shared env vars once (they apply across all targets) let sharedEnvVars: Array<{ @@ -1350,7 +1334,8 @@ export class VercelIntegrationRepository { client, accessToken, params.teamId, - params.vercelProjectId + params.vercelProjectId, + allowEmptyValues ); sharedEnvVars = sharedResult.unwrapOr([]); } @@ -1373,7 +1358,8 @@ export class VercelIntegrationRepository { params.vercelProjectId, params.teamId, mapping.vercelTarget, - shouldIncludeKey + shouldIncludeKey, + allowEmptyValues ); if (envVarsResult.isErr()) { @@ -1399,11 +1385,10 @@ export class VercelIntegrationRepository { return matchesTarget || matchesCustomEnv; }); - const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); - const sharedEnvVarsToAdd = filteredSharedEnvVars.filter( - (v) => !projectEnvVarKeys.has(v.key) + const mergedEnvVars = mergeVercelEnvironmentVariableValues( + projectEnvVars, + filteredSharedEnvVars ); - const mergedEnvVars = [...projectEnvVars, ...sharedEnvVarsToAdd]; if (mergedEnvVars.length === 0) { return; diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 4a217b568bc..87d59b04687 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,3 +1,4 @@ +import { emptyEnvironmentVariableValuesEnabled } from "~/v3/environmentVariables/emptyValuesFlag.server"; import type { PrismaClient, PrismaReplicaClient } from "~/db.server"; import { $replica, prisma } from "~/db.server"; import type { Project } from "~/models/project.server"; @@ -39,6 +40,7 @@ export class EnvironmentVariablesPresenter { const project = await this.#replicaClient.project.findFirst({ select: { id: true, + organization: { select: { featureFlags: true } }, }, where: { slug: projectSlug, @@ -176,6 +178,10 @@ export class EnvironmentVariablesPresenter { } return { + allowEmptyEnvironmentVariableValues: await emptyEnvironmentVariableValuesEnabled( + project.organization.featureFlags, + this.#prismaClient + ), environmentVariables: environmentVariables.flatMap((environmentVariable) => { return sortedEnvironments.flatMap((env) => { const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 7ba44ff4ee8..bf9af682105 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -1,5 +1,9 @@ import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod/v4"; +import { + emptyEnvironmentVariableValuesEnabled, + EMPTY_ENV_VALUES_DISABLED, +} from "~/v3/environmentVariables/emptyValuesFlag.server"; +import { parseEnvironmentVariableForm } from "~/v3/environmentVariables/forms"; import { LockClosedIcon, LockOpenIcon, @@ -54,7 +58,10 @@ import { v3EnvironmentVariablesPath, } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; -import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; +import { + EnvironmentVariableKey, + EnvironmentVariableValue, +} from "~/v3/environmentVariables/repository"; import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments"; import { pageMeta } from "~/utils/pageTitle"; @@ -62,7 +69,7 @@ export const meta = pageMeta("New environment variable"); const Variable = z.object({ key: EnvironmentVariableKey, - value: z.string().nonempty("Value is required"), + value: EnvironmentVariableValue, }); type Variable = z.infer; @@ -121,7 +128,7 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema }); + const submission = parseEnvironmentVariableForm(formData, schema); if (submission.status !== "success") { return json(submission.reply()); @@ -162,6 +169,7 @@ export const action = dashboardAction( }, select: { id: true, + organization: { select: { featureFlags: true } }, }, }); if (!project) { @@ -193,6 +201,13 @@ export const action = dashboardAction( ); } + if ( + submission.value.variables.some((v) => v.value.trim() === "") && + !(await emptyEnvironmentVariableValuesEnabled(project.organization.featureFlags)) + ) { + return json(submission.reply({ formErrors: [EMPTY_ENV_VALUES_DISABLED] })); + } + const repository = new EnvironmentVariablesRepository(prisma); const result = await repository.create(project.id, { ...submission.value, @@ -238,7 +253,8 @@ export default function Page() { parentData, "Environment variables page loader data must be defined when rendering the create dialog" ); - const { environments, hasStaging, writableEnvironmentIds } = parentData; + const { environments, hasStaging, writableEnvironmentIds, allowEmptyEnvironmentVariableValues } = + parentData; // Creating a variable is a write, so gate the targets on write access. const writableEnvironmentIdSet = new Set(writableEnvironmentIds); const lastSubmission = useActionData(); @@ -266,7 +282,7 @@ export default function Page() { // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseEnvironmentVariableForm(formData, schema, allowEmptyEnvironmentVariableValues); }, shouldRevalidate: "onSubmit", defaultValue: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 2cb1758661b..ec76f56374e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -1,5 +1,6 @@ +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { getFormProps, getInputProps, useForm } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod/v4"; +import { parseEnvironmentVariableForm } from "~/v3/environmentVariables/forms"; import { BookOpenIcon, InformationCircleIcon, @@ -108,6 +109,7 @@ export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & { }; export type EnvironmentVariablesPageLoaderData = { + allowEmptyEnvironmentVariableValues: boolean; environmentVariables: MaskedEnvironmentVariable[]; environments: EnvironmentVariablesEnvironment[]; hasStaging: boolean; @@ -142,13 +144,19 @@ export const loader = dashboardLoader( try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration, pagination } = - await presenter.call({ - userId: user.id, - projectSlug: projectParam, - page: searchParams.page, - search: searchParams.search, - }); + const { + environmentVariables, + environments, + hasStaging, + vercelIntegration, + pagination, + allowEmptyEnvironmentVariableValues, + } = await presenter.call({ + userId: user.id, + projectSlug: projectParam, + page: searchParams.page, + search: searchParams.search, + }); const accessibleEnvironmentIds = environments .filter((env) => ability.can("read", { type: "envvars", envType: env.type })) @@ -178,6 +186,7 @@ export const loader = dashboardLoader( return typedjson({ environmentVariables: masked, + allowEmptyEnvironmentVariableValues, environments, hasStaging, vercelIntegration, @@ -232,7 +241,7 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema }); + const submission = parseEnvironmentVariableForm(formData, schema); if (submission.status !== "success") { return json(submission.reply()); @@ -808,6 +817,10 @@ function EditEnvironmentVariablePanel({ revealAll: boolean; }) { const [isOpen, setIsOpen] = useState(false); + const { allowEmptyEnvironmentVariableValues } = + useTypedLoaderData(); + const [emptyValueSelected, setEmptyValueSelected] = useState(false); + const [draftValue, setDraftValue] = useState(variable.value); const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; @@ -826,13 +839,22 @@ function EditEnvironmentVariablePanel({ // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseEnvironmentVariableForm(formData, schema, allowEmptyEnvironmentVariableValues); }, shouldRevalidate: "onSubmit", }); return ( - + { + setIsOpen(open); + if (open) { + setEmptyValueSelected(false); + setDraftValue(variable.value); + } + }} + >