Skip to content

Commit 1d55abf

Browse files
authored
chore(trigger): sync Trigger.dev env vars from the app's Secrets Manager secret, gated per environment (#9632)
* chore(trigger): sync Trigger.dev env vars from the app's Secrets Manager secret, gated per environment * fix(trigger): re-read values before flagging secrets so a rotation is never undone
1 parent 1e0a887 commit 1d55abf

8 files changed

Lines changed: 320 additions & 18 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ jobs:
249249
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
250250
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
251251
timeout-minutes: 30
252+
permissions:
253+
contents: read
254+
id-token: write
252255
outputs:
253256
version: ${{ steps.deploy.outputs.deploymentVersion }}
254257
environment: ${{ steps.target.outputs.environment }}
@@ -274,6 +277,15 @@ jobs:
274277
echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT"
275278
echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT"
276279
280+
# The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's
281+
# env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret.
282+
- name: Configure AWS credentials for the env sync
283+
if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), format(',{0},', steps.target.outputs.environment))
284+
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
285+
with:
286+
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
287+
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
288+
277289
- name: Upload Trigger.dev version without promotion
278290
id: deploy
279291
working-directory: ./apps/sim
@@ -282,6 +294,7 @@ jobs:
282294
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
283295
TRIGGER_ENV: ${{ steps.target.outputs.environment }}
284296
TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }}
297+
TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }}
285298
run: |
286299
set -eo pipefail
287300
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then

‎.github/workflows/trigger-dev.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ jobs:
1919
name: deploy
2020
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
2121
timeout-minutes: 30
22+
permissions:
23+
contents: read
24+
id-token: write
2225
steps:
2326
- name: Checkout code
2427
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
@@ -31,12 +34,22 @@ jobs:
3134
provider: ${{ vars.CI_PROVIDER }}
3235
node-version: ''
3336

37+
# The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's
38+
# env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret.
39+
- name: Configure AWS credentials for the env sync
40+
if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), ',preview,')
41+
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
42+
with:
43+
role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }}
44+
aws-region: ${{ secrets.DEV_AWS_REGION }}
45+
3446
- name: Upload preview version
3547
id: deploy
3648
working-directory: ./apps/sim
3749
env:
3850
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN_PREVIEW || secrets.TRIGGER_ACCESS_TOKEN }}
3951
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
52+
TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }}
4053
run: |
4154
set -euo pipefail
4255
: "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}"

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export const env = createEnv({
346346
TRIGGER_PROJECT_ID: z.string().optional(), // Trigger.dev project ID
347347
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
348348
TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs
349+
TRIGGER_ENV_SYNC_TARGETS: z.string().optional(), // Deploy-time only: comma-separated Trigger.dev environments (prod,staging,preview) synced from the app's env-vars secret
349350
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
350351
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('30'),
351352
WORKFLOW_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('75'),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Shows what syncing a Trigger.dev environment from the app's env-vars secret
5+
* would change, by variable name only, before the sync is enabled for it.
6+
*
7+
* Reads the app secret, builds the variables the deploy would sync (the same
8+
* `buildTriggerEnvVars` the `syncEnvVars` extension uses), and compares them
9+
* with the values the environment holds now, read through the environment's
10+
* API key exactly as the deploy CLI reads them. Never prints a value.
11+
*
12+
* The deploy only sends variables whose value changed, so an unchanged
13+
* variable stored as plain text stays plain. `--apply-secret-flags` marks
14+
* those as secrets with their current value, which is otherwise untouched.
15+
*
16+
* Usage:
17+
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts staging
18+
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts preview
19+
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts prod --apply-secret-flags
20+
*/
21+
22+
import { createLogger } from '@sim/logger'
23+
import { fetchRuntimeSecrets } from '@sim/runtime-secrets'
24+
import {
25+
APP_ENV_SECRET_IDS,
26+
buildTriggerEnvVars,
27+
diffTriggerEnv,
28+
} from '@sim/runtime-secrets/trigger-env'
29+
import { getErrorMessage } from '@sim/utils/errors'
30+
import { chunkArray } from '@sim/utils/helpers'
31+
32+
const logger = createLogger('PlanTriggerEnvSync')
33+
34+
const TRIGGER_API_URL = 'https://api.trigger.dev'
35+
36+
/** Variables per import call when flipping secret flags. */
37+
const IMPORT_BATCH_SIZE = 50
38+
39+
interface ListedVariable {
40+
name: string
41+
isSecret?: boolean
42+
}
43+
44+
async function main() {
45+
const environment = process.argv[2] ?? ''
46+
const applySecretFlags = process.argv.includes('--apply-secret-flags')
47+
const secretId = APP_ENV_SECRET_IDS[environment]
48+
if (!secretId) {
49+
throw new Error(
50+
`Usage: plan-trigger-env-sync.ts <${Object.keys(APP_ENV_SECRET_IDS).join('|')}> [--apply-secret-flags]`
51+
)
52+
}
53+
54+
const appEnv = await fetchRuntimeSecrets(secretId)
55+
const { TRIGGER_PROJECT_ID: project, TRIGGER_SECRET_KEY: apiKey } = appEnv
56+
if (!project || !apiKey) {
57+
throw new Error(`${secretId} has no TRIGGER_PROJECT_ID/TRIGGER_SECRET_KEY`)
58+
}
59+
const branch = environment === 'preview' ? appEnv.TRIGGER_PREVIEW_BRANCH : undefined
60+
if (environment === 'preview' && !branch) {
61+
throw new Error(`${secretId} has no TRIGGER_PREVIEW_BRANCH`)
62+
}
63+
64+
const headers = {
65+
Authorization: `Bearer ${apiKey}`,
66+
'Content-Type': 'application/json',
67+
...(branch ? { 'x-trigger-branch': branch } : {}),
68+
}
69+
const envvarsUrl = `${TRIGGER_API_URL}/api/v1/projects/${project}/envvars`
70+
async function request<T>(url: string, body?: unknown): Promise<T> {
71+
const response = await fetch(url, {
72+
method: body === undefined ? 'GET' : 'POST',
73+
headers,
74+
body: body === undefined ? undefined : JSON.stringify(body),
75+
})
76+
if (!response.ok) throw new Error(`${url} failed with ${response.status}`)
77+
return (await response.json()) as T
78+
}
79+
async function listPlainNames(): Promise<Set<string>> {
80+
const listed = await request<ListedVariable[] | { data: ListedVariable[] }>(
81+
`${envvarsUrl}/${environment}`
82+
)
83+
const variables = Array.isArray(listed) ? listed : listed.data
84+
return new Set(variables.filter((v) => !v.isSecret).map((v) => v.name))
85+
}
86+
87+
const [{ variables: current }, plainNames] = await Promise.all([
88+
request<{ variables: Record<string, string> }>(envvarsUrl),
89+
listPlainNames(),
90+
])
91+
92+
const synced = buildTriggerEnvVars(appEnv)
93+
const syncedNames = new Set(synced.map((v) => v.name))
94+
const { added, changed } = diffTriggerEnv(synced, current)
95+
const toFlag = synced.filter(
96+
(v) => v.isSecret && plainNames.has(v.name) && current[v.name] === v.value
97+
)
98+
const names = (vars: { name: string }[]) => vars.map((v) => v.name).sort()
99+
100+
logger.info('Trigger.dev env sync plan', {
101+
environment,
102+
branch,
103+
secretId,
104+
synced: synced.length,
105+
unchanged: synced.length - added.length - changed.length,
106+
added: names(added),
107+
changed: names(changed),
108+
plainToSecretOnSync: names(changed.filter((v) => v.isSecret && plainNames.has(v.name))),
109+
plainUnchangedNeedingSecretFlag: names(toFlag),
110+
appKeysNotSynced: Object.keys(appEnv)
111+
.filter((name) => !syncedNames.has(name))
112+
.sort(),
113+
})
114+
115+
if (!applySecretFlags || toFlag.length === 0) return
116+
// The import resubmits each value, so re-read right before each batch and skip any variable
117+
// rotated since the plan: flagging must never write back an older value.
118+
const skipped: string[] = []
119+
for (const batch of chunkArray(toFlag, IMPORT_BATCH_SIZE)) {
120+
const { variables: latest } = await request<{ variables: Record<string, string> }>(envvarsUrl)
121+
const unrotated = batch.filter((v) => latest[v.name] === v.value)
122+
skipped.push(...batch.filter((v) => latest[v.name] !== v.value).map((v) => v.name))
123+
if (unrotated.length === 0) continue
124+
await request(`${envvarsUrl}/${environment}/import`, {
125+
variables: Object.fromEntries(unrotated.map((v) => [v.name, v.value])),
126+
override: true,
127+
isSecret: true,
128+
})
129+
}
130+
const stillPlain = await listPlainNames()
131+
const unflagged = names(toFlag.filter((v) => stillPlain.has(v.name) && !skipped.includes(v.name)))
132+
if (unflagged.length > 0) throw new Error(`Still plain after flagging: ${unflagged.join(', ')}`)
133+
logger.info('Marked unchanged variables as secrets', {
134+
environment,
135+
flagged: toFlag.length - skipped.length,
136+
skippedAsRotated: skipped.sort(),
137+
})
138+
}
139+
140+
main().catch((error) => {
141+
logger.error('Trigger.dev env sync plan failed', { error: getErrorMessage(error) })
142+
process.exit(1)
143+
})

‎apps/sim/trigger.config.ts‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
33
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
44
import { resourceFromAttributes } from '@opentelemetry/resources'
55
import { createLogger } from '@sim/logger'
6+
import {
7+
diffTriggerEnv,
8+
resolveTriggerEnvFromAppSecret,
9+
TRIGGER_CONSTANT_VARS,
10+
} from '@sim/runtime-secrets/trigger-env'
611
import { distrustCompromisedRoots } from '@sim/security/tls-trust'
12+
import { getErrorMessage } from '@sim/utils/errors'
713
import type { BuildExtension } from '@trigger.dev/build'
814
import {
915
additionalFiles,
@@ -66,6 +72,35 @@ function getFunctionExecutionEnvVars() {
6672
})
6773
}
6874

75+
/**
76+
* Mirrors the app's env-vars secret into an environment listed in
77+
* `TRIGGER_ENV_SYNC_TARGETS`, logging only counts, never names or values (CI
78+
* logs are public). Exits on failure: `syncEnvVars` catches a thrown error and
79+
* deploys without syncing, and a failed deploy leaves the running version and
80+
* its environment untouched where a silent skip would keep stale configuration.
81+
*/
82+
async function syncFromAppSecret(environment: string, deployedEnv: Record<string, string>) {
83+
const logger = createLogger('TriggerEnvSync')
84+
try {
85+
const { secretId, vars } = await resolveTriggerEnvFromAppSecret(environment)
86+
const { added, changed } = diffTriggerEnv(vars, deployedEnv)
87+
logger.info('Syncing the Trigger.dev environment from the app env-vars secret', {
88+
environment,
89+
secretId,
90+
total: vars.length,
91+
added: added.length,
92+
changed: changed.length,
93+
})
94+
return vars
95+
} catch (error) {
96+
logger.error('Trigger.dev env sync failed; aborting the deploy', {
97+
environment,
98+
error: getErrorMessage(error),
99+
})
100+
process.exit(1)
101+
}
102+
}
103+
69104
/**
70105
* Node.js version copied into the task image, in lockstep with `NODE_VERSION`
71106
* in `docker/app.Dockerfile`: `isolated-vm` 6.x loads its Node 24 (ABI 137)
@@ -198,17 +233,11 @@ export default defineConfig({
198233
],
199234
extensions: [
200235
nodeBinary(),
201-
syncEnvVars(() => [
202-
{ name: 'DB_APP_NAME', value: 'sim-trigger' },
203-
/**
204-
* Workers run Trigger.dev by definition, but the flag saying so was only
205-
* set on the app container. Syncing it keeps the deployment flag honest
206-
* inside runs; the dispatch decision itself no longer depends on it,
207-
* because the `init` hook above marks the run process directly.
208-
*/
209-
{ name: 'TRIGGER_DEV_ENABLED', value: 'TRUE' },
210-
...getFunctionExecutionEnvVars(),
211-
]),
236+
syncEnvVars(({ environment, env: deployedEnv }) =>
237+
env.TRIGGER_ENV_SYNC_TARGETS?.split(',').includes(environment)
238+
? syncFromAppSecret(environment, deployedEnv)
239+
: [...TRIGGER_CONSTANT_VARS, ...getFunctionExecutionEnvVars()]
240+
),
212241
additionalFiles({
213242
files: [
214243
'./lib/execution/isolated-vm-worker.cjs',

‎packages/runtime-secrets/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
".": {
1414
"types": "./src/index.ts",
1515
"default": "./src/index.ts"
16+
},
17+
"./trigger-env": {
18+
"types": "./src/trigger-env.ts",
19+
"default": "./src/trigger-env.ts"
1620
}
1721
},
1822
"scripts": {

‎packages/runtime-secrets/src/index.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,7 @@ export async function loadRuntimeSecrets(): Promise<void> {
3333
return
3434
}
3535

36-
const client = new SecretsManagerClient(
37-
process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}
38-
)
39-
40-
const secretString = await fetchSecretString(client, secretId)
41-
const entries = parseSecretJson(secretString)
36+
const entries = await fetchRuntimeSecrets(secretId)
4237

4338
let loaded = 0
4439
let skipped = 0
@@ -47,13 +42,32 @@ export async function loadRuntimeSecrets(): Promise<void> {
4742
skipped++
4843
continue
4944
}
50-
process.env[key] = typeof value === 'string' ? value : JSON.stringify(value)
45+
process.env[key] = value
5146
loaded++
5247
}
5348

5449
logger.info('Runtime secrets ingested', { secretId, loaded, skipped })
5550
}
5651

52+
/**
53+
* Reads one env-vars JSON secret as the string map a process would see: the
54+
* same values {@link loadRuntimeSecrets} writes into `process.env`, with
55+
* non-string JSON values serialized. Retries transient failures and throws on
56+
* anything else, so a caller never proceeds on a partial or missing secret.
57+
*/
58+
export async function fetchRuntimeSecrets(secretId: string): Promise<Record<string, string>> {
59+
const client = new SecretsManagerClient(
60+
process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}
61+
)
62+
const entries = parseSecretJson(await fetchSecretString(client, secretId))
63+
return Object.fromEntries(
64+
Object.entries(entries).map(([key, value]) => [
65+
key,
66+
typeof value === 'string' ? value : JSON.stringify(value),
67+
])
68+
)
69+
}
70+
5771
async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise<string> {
5872
const response = await sendWithRetry(client, secretId)
5973
if (!response.SecretString) {

0 commit comments

Comments
 (0)