Skip to content

Commit 983bd03

Browse files
authored
feat: support isSecret in syncEnvVars (#4203)
## What Adds per-variable secret support to the `syncEnvVars` build extension. Return `{ name, value, isSecret: true }` and the variable is stored as a secret (redacted in the dashboard, value non-revealable), just like a manually created secret env var. Secret and non-secret variables can be mixed in one callback. ```ts syncEnvVars(async () => [ { name: "PUBLIC_API_URL", value: "https://api.example.com" }, { name: "DATABASE_URL", value: "postgres://...", isSecret: true }, ]); ``` ## How Env vars flow through the build pipeline as a flat name→value map, and the import API's `isSecret` is per-call. So secret vars are carried through the layer + manifest in parallel `secretEnv` / `secretParentEnv` maps, and at deploy time they go up in a second `importEnvVars` call with `isSecret: true` (the plain vars in the first call). The record form (`{ KEY: "value" }`) is unchanged and stays non-secret. ## Commits - `feat(core)`: carry secret env vars through the build layer + manifest schema - `feat(build)`: partition `isSecret` vars in `syncEnvVars` - `feat(cli)`: merge secret layers and import them with `isSecret: true` at deploy - `test(build)`: cover the partitioning + document `isSecret` ## Testing - vitest covers the partitioning (secret/non-secret × child/parent) and that the record form stays non-secret. - Verified against a local webapp that the deploy's import contract stores the secret var redacted (`isSecret: true`) and the plain var visible. Closes TRI-11099
1 parent 7faa525 commit 983bd03

11 files changed

Lines changed: 284 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/build": patch
3+
---
4+
5+
You can now mark environment variables synced via the `syncEnvVars` build extension as secrets. Return `{ name, value, isSecret: true }` from your callback and those variables are stored redacted in the dashboard, just like manually created secret env vars.

docs/config/extensions/syncEnvVars.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,33 @@ The callback is passed a context object with the following properties:
3232
- `projectRef`: The project ref of the Trigger.dev project
3333
- `env`: The environment variables that are currently set in the Trigger.dev project
3434

35+
### Marking variables as secrets
36+
37+
Return the array form and set `isSecret: true` on any variable you want stored as a [secret](/deploy-environment-variables). Secret env vars are redacted in the dashboard and their values can't be revealed after they're set, just like manually created secret variables. You can mix secret and non-secret variables in the same callback.
38+
39+
```ts
40+
import { defineConfig } from "@trigger.dev/sdk";
41+
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
42+
43+
export default defineConfig({
44+
build: {
45+
extensions: [
46+
syncEnvVars(async (ctx) => {
47+
return [
48+
{ name: "PUBLIC_API_URL", value: "https://api.example.com" },
49+
{ name: "DATABASE_URL", value: "postgres://...", isSecret: true },
50+
];
51+
}),
52+
],
53+
},
54+
});
55+
```
56+
57+
<Note>
58+
`isSecret` is only available when you return the array form (`{ name, value, isSecret }`). The
59+
record form (`{ KEY: "value" }`) always creates non-secret variables.
60+
</Note>
61+
3562
### Example: Sync env vars from Infisical
3663

3764
In this example we're using env vars from [Infisical](https://infisical.com).

packages/build/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"dev": "tshy --watch",
7575
"typecheck": "tsc --noEmit -p tsconfig.src.json",
7676
"update-version": "tsx ../../scripts/updateVersion.ts",
77-
"check-exports": "attw --pack ."
77+
"check-exports": "attw --pack .",
78+
"test": "vitest"
7879
},
7980
"dependencies": {
8081
"@prisma/config": "^6.10.0",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { BuildContext, BuildLayer } from "@trigger.dev/core/v3/build";
3+
import { syncEnvVars, type SyncEnvVarsFunction } from "./syncEnvVars.js";
4+
5+
async function runExtension(fn: SyncEnvVarsFunction): Promise<BuildLayer | undefined> {
6+
let captured: BuildLayer | undefined;
7+
8+
const context = {
9+
target: "deploy",
10+
config: { project: "proj_test" },
11+
logger: {
12+
spinner: () => ({ stop: () => {} }),
13+
},
14+
addLayer: (layer: BuildLayer) => {
15+
captured = layer;
16+
},
17+
} as unknown as BuildContext;
18+
19+
const manifest = {
20+
deploy: { env: {} },
21+
environment: "prod",
22+
branch: undefined,
23+
} as any;
24+
25+
const extension = syncEnvVars(fn);
26+
await extension.onBuildComplete!(context, manifest);
27+
28+
return captured;
29+
}
30+
31+
describe("syncEnvVars isSecret", () => {
32+
it("partitions secret and non-secret vars across child and parent", async () => {
33+
const layer = await runExtension(() => [
34+
{ name: "PUBLIC_URL", value: "https://example.com" },
35+
{ name: "API_KEY", value: "secret-key", isSecret: true },
36+
{ name: "PARENT_PUBLIC", value: "parent", isParentEnv: true },
37+
{ name: "PARENT_SECRET", value: "parent-secret", isParentEnv: true, isSecret: true },
38+
]);
39+
40+
expect(layer?.deploy?.env).toEqual({ PUBLIC_URL: "https://example.com" });
41+
expect(layer?.deploy?.secretEnv).toEqual({ API_KEY: "secret-key" });
42+
expect(layer?.deploy?.parentEnv).toEqual({ PARENT_PUBLIC: "parent" });
43+
expect(layer?.deploy?.secretParentEnv).toEqual({ PARENT_SECRET: "parent-secret" });
44+
});
45+
46+
it("treats the record form as all non-secret", async () => {
47+
const layer = await runExtension(() => ({ DATABASE_URL: "postgres://..." }));
48+
49+
expect(layer?.deploy?.env).toEqual({ DATABASE_URL: "postgres://..." });
50+
expect(layer?.deploy?.secretEnv).toBeUndefined();
51+
expect(layer?.deploy?.secretParentEnv).toBeUndefined();
52+
});
53+
54+
it("omits secret buckets when no var is marked secret", async () => {
55+
const layer = await runExtension(() => [{ name: "PUBLIC_URL", value: "https://example.com" }]);
56+
57+
expect(layer?.deploy?.env).toEqual({ PUBLIC_URL: "https://example.com" });
58+
expect(layer?.deploy?.secretEnv).toBeUndefined();
59+
expect(layer?.deploy?.secretParentEnv).toBeUndefined();
60+
});
61+
});

packages/build/src/extensions/core/syncEnvVars.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
22

33
export type SyncEnvVarsBody =
44
| Record<string, string>
5-
| Array<{ name: string; value: string; isParentEnv?: boolean }>;
5+
| Array<{ name: string; value: string; isParentEnv?: boolean; isSecret?: boolean }>;
66

77
export type SyncEnvVarsResult =
88
| SyncEnvVarsBody
@@ -100,9 +100,16 @@ export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOption
100100

101101
const env = stripUnsyncableEnvVars(result.env);
102102
const parentEnv = result.parentEnv ? stripUnsyncableEnvVars(result.parentEnv) : undefined;
103+
const secretEnv = result.secretEnv ? stripUnsyncableEnvVars(result.secretEnv) : undefined;
104+
const secretParentEnv = result.secretParentEnv
105+
? stripUnsyncableEnvVars(result.secretParentEnv)
106+
: undefined;
103107

104108
const numberOfEnvVars =
105-
Object.keys(env).length + (parentEnv ? Object.keys(parentEnv).length : 0);
109+
Object.keys(env).length +
110+
(parentEnv ? Object.keys(parentEnv).length : 0) +
111+
(secretEnv ? Object.keys(secretEnv).length : 0) +
112+
(secretParentEnv ? Object.keys(secretParentEnv).length : 0);
106113

107114
if (numberOfEnvVars === 0) {
108115
$spinner.stop("No env vars detected");
@@ -119,6 +126,8 @@ export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOption
119126
deploy: {
120127
env,
121128
parentEnv,
129+
secretEnv,
130+
secretParentEnv,
122131
override: options?.override ?? true,
123132
},
124133
});
@@ -151,9 +160,22 @@ async function callSyncEnvVarsFn(
151160
environment: string,
152161
branch: string | undefined,
153162
context: BuildContext
154-
): Promise<{ env: Record<string, string>; parentEnv?: Record<string, string> } | undefined> {
163+
): Promise<
164+
| {
165+
env: Record<string, string>;
166+
parentEnv?: Record<string, string>;
167+
secretEnv?: Record<string, string>;
168+
secretParentEnv?: Record<string, string>;
169+
}
170+
| undefined
171+
> {
155172
if (syncEnvVarsFn && typeof syncEnvVarsFn === "function") {
156-
let resolvedEnvVars: { env: Record<string, string>; parentEnv?: Record<string, string> } = {
173+
let resolvedEnvVars: {
174+
env: Record<string, string>;
175+
parentEnv?: Record<string, string>;
176+
secretEnv?: Record<string, string>;
177+
secretParentEnv?: Record<string, string>;
178+
} = {
157179
env: {},
158180
};
159181
let result;
@@ -184,10 +206,16 @@ async function callSyncEnvVarsFn(
184206
typeof item.value === "string"
185207
) {
186208
if (item.isParentEnv) {
187-
if (!resolvedEnvVars.parentEnv) {
188-
resolvedEnvVars.parentEnv = {};
209+
if (item.isSecret) {
210+
resolvedEnvVars.secretParentEnv ??= {};
211+
resolvedEnvVars.secretParentEnv[item.name] = item.value;
212+
} else {
213+
resolvedEnvVars.parentEnv ??= {};
214+
resolvedEnvVars.parentEnv[item.name] = item.value;
189215
}
190-
resolvedEnvVars.parentEnv[item.name] = item.value;
216+
} else if (item.isSecret) {
217+
resolvedEnvVars.secretEnv ??= {};
218+
resolvedEnvVars.secretEnv[item.name] = item.value;
191219
} else {
192220
resolvedEnvVars.env[item.name] = item.value;
193221
}

packages/build/vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["src/**/*.test.ts"],
6+
globals: true,
7+
},
8+
});

packages/cli-v3/src/build/extensions.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,46 @@ function applyLayerToManifest(layer: BuildLayer, manifest: BuildManifest): Build
185185
}
186186
}
187187

188+
if (layer.deploy?.secretEnv) {
189+
$manifest.deploy.env ??= {};
190+
$manifest.deploy.sync ??= {};
191+
$manifest.deploy.sync.secretEnv ??= {};
192+
193+
for (const [key, value] of Object.entries(layer.deploy.secretEnv)) {
194+
if (!value) {
195+
continue;
196+
}
197+
198+
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
199+
const existingValue = $manifest.deploy.env[key];
200+
201+
if (existingValue !== value) {
202+
$manifest.deploy.sync.secretEnv[key] = value;
203+
}
204+
}
205+
}
206+
}
207+
208+
if (layer.deploy?.secretParentEnv) {
209+
$manifest.deploy.env ??= {};
210+
$manifest.deploy.sync ??= {};
211+
$manifest.deploy.sync.secretParentEnv ??= {};
212+
213+
for (const [key, value] of Object.entries(layer.deploy.secretParentEnv)) {
214+
if (!value) {
215+
continue;
216+
}
217+
218+
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
219+
const existingValue = $manifest.deploy.env[key];
220+
221+
if (existingValue !== value) {
222+
$manifest.deploy.sync.secretParentEnv[key] = value;
223+
}
224+
}
225+
}
226+
}
227+
188228
if (layer.dependencies) {
189229
const externals = $manifest.externals ?? [];
190230

packages/cli-v3/src/commands/deploy.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -454,16 +454,23 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
454454
}
455455
}
456456

457+
const childVars = buildManifest.deploy.sync?.env ?? {};
458+
const parentVars = buildManifest.deploy.sync?.parentEnv ?? {};
459+
const secretChildVars = buildManifest.deploy.sync?.secretEnv ?? {};
460+
const secretParentVars = buildManifest.deploy.sync?.secretParentEnv ?? {};
461+
457462
const hasVarsToSync =
458-
Object.keys(buildManifest.deploy.sync?.env || {}).length > 0 ||
463+
Object.keys(childVars).length > 0 ||
464+
Object.keys(secretChildVars).length > 0 ||
459465
// Only sync parent variables if this is a branch environment
460-
(branch && Object.keys(buildManifest.deploy.sync?.parentEnv || {}).length > 0);
466+
(branch && (Object.keys(parentVars).length > 0 || Object.keys(secretParentVars).length > 0));
461467

462468
if (hasVarsToSync) {
463-
const childVars = buildManifest.deploy.sync?.env ?? {};
464-
const parentVars = buildManifest.deploy.sync?.parentEnv ?? {};
465-
466-
const numberOfEnvVars = Object.keys(childVars).length + Object.keys(parentVars).length;
469+
const numberOfEnvVars =
470+
Object.keys(childVars).length +
471+
Object.keys(parentVars).length +
472+
Object.keys(secretChildVars).length +
473+
Object.keys(secretParentVars).length;
467474
const vars = numberOfEnvVars === 1 ? "var" : "vars";
468475

469476
if (!options.skipSyncEnvVars) {
@@ -475,7 +482,9 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
475482
resolvedConfig.project,
476483
options.env,
477484
childVars,
478-
parentVars
485+
parentVars,
486+
secretChildVars,
487+
secretParentVars
479488
);
480489

481490
if (!uploadResult.success) {
@@ -768,13 +777,41 @@ export async function syncEnvVarsWithServer(
768777
projectRef: string,
769778
environmentSlug: string,
770779
envVars: Record<string, string>,
771-
parentEnvVars?: Record<string, string>
780+
parentEnvVars?: Record<string, string>,
781+
secretEnvVars?: Record<string, string>,
782+
secretParentEnvVars?: Record<string, string>
772783
) {
773-
return await apiClient.importEnvVars(projectRef, environmentSlug, {
774-
variables: envVars,
775-
parentVariables: parentEnvVars,
776-
override: true,
777-
});
784+
const hasNonSecret =
785+
Object.keys(envVars).length > 0 || Object.keys(parentEnvVars ?? {}).length > 0;
786+
const hasSecret =
787+
Object.keys(secretEnvVars ?? {}).length > 0 ||
788+
Object.keys(secretParentEnvVars ?? {}).length > 0;
789+
790+
// The import API applies isSecret per call, so secret and non-secret vars go in separate calls.
791+
// Default to success so an all-empty call (no vars to sync) is a no-op, not undefined.
792+
let result: Awaited<ReturnType<typeof apiClient.importEnvVars>> = {
793+
success: true,
794+
data: { success: true },
795+
};
796+
797+
if (hasNonSecret) {
798+
result = await apiClient.importEnvVars(projectRef, environmentSlug, {
799+
variables: envVars,
800+
parentVariables: parentEnvVars,
801+
override: true,
802+
});
803+
}
804+
805+
if (hasSecret && result.success) {
806+
result = await apiClient.importEnvVars(projectRef, environmentSlug, {
807+
variables: secretEnvVars ?? {},
808+
parentVariables: secretParentEnvVars,
809+
override: true,
810+
isSecret: true,
811+
});
812+
}
813+
814+
return result;
778815
}
779816

780817
async function failDeploy(

0 commit comments

Comments
 (0)