Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ jobs:
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
permissions:
contents: read
id-token: write
outputs:
version: ${{ steps.deploy.outputs.deploymentVersion }}
environment: ${{ steps.target.outputs.environment }}
Expand All @@ -274,6 +277,15 @@ jobs:
echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT"
echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT"

# The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's
# env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret.
- name: Configure AWS credentials for the env sync
if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), format(',{0},', steps.target.outputs.environment))
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
with:
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}

- name: Upload Trigger.dev version without promotion
id: deploy
working-directory: ./apps/sim
Expand All @@ -282,6 +294,7 @@ jobs:
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
TRIGGER_ENV: ${{ steps.target.outputs.environment }}
TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }}
TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }}
run: |
set -eo pipefail
if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/trigger-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
name: deploy
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
permissions:
contents: read
id-token: write
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
Expand All @@ -31,12 +34,22 @@ jobs:
provider: ${{ vars.CI_PROVIDER }}
node-version: ''

# The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's
# env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret.
- name: Configure AWS credentials for the env sync
if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), ',preview,')
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6
with:
role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ secrets.DEV_AWS_REGION }}

- name: Upload preview version
id: deploy
working-directory: ./apps/sim
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN_PREVIEW || secrets.TRIGGER_ACCESS_TOKEN }}
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }}
run: |
set -euo pipefail
: "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}"
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export const env = createEnv({
TRIGGER_PROJECT_ID: z.string().optional(), // Trigger.dev project ID
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs
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
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
SCHEDULE_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('30'),
WORKFLOW_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('75'),
Expand Down
143 changes: 143 additions & 0 deletions apps/sim/scripts/plan-trigger-env-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env bun

/**
* Shows what syncing a Trigger.dev environment from the app's env-vars secret
* would change, by variable name only, before the sync is enabled for it.
*
* Reads the app secret, builds the variables the deploy would sync (the same
* `buildTriggerEnvVars` the `syncEnvVars` extension uses), and compares them
* with the values the environment holds now, read through the environment's
* API key exactly as the deploy CLI reads them. Never prints a value.
*
* The deploy only sends variables whose value changed, so an unchanged
* variable stored as plain text stays plain. `--apply-secret-flags` marks
* those as secrets with their current value, which is otherwise untouched.
*
* Usage:
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts staging
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts preview
* AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts prod --apply-secret-flags
*/

import { createLogger } from '@sim/logger'
import { fetchRuntimeSecrets } from '@sim/runtime-secrets'
import {
APP_ENV_SECRET_IDS,
buildTriggerEnvVars,
diffTriggerEnv,
} from '@sim/runtime-secrets/trigger-env'
import { getErrorMessage } from '@sim/utils/errors'
import { chunkArray } from '@sim/utils/helpers'

const logger = createLogger('PlanTriggerEnvSync')

const TRIGGER_API_URL = 'https://api.trigger.dev'

/** Variables per import call when flipping secret flags. */
const IMPORT_BATCH_SIZE = 50

interface ListedVariable {
name: string
isSecret?: boolean
}

async function main() {
const environment = process.argv[2] ?? ''
const applySecretFlags = process.argv.includes('--apply-secret-flags')
const secretId = APP_ENV_SECRET_IDS[environment]
if (!secretId) {
throw new Error(
`Usage: plan-trigger-env-sync.ts <${Object.keys(APP_ENV_SECRET_IDS).join('|')}> [--apply-secret-flags]`
)
}

const appEnv = await fetchRuntimeSecrets(secretId)
const { TRIGGER_PROJECT_ID: project, TRIGGER_SECRET_KEY: apiKey } = appEnv
if (!project || !apiKey) {
throw new Error(`${secretId} has no TRIGGER_PROJECT_ID/TRIGGER_SECRET_KEY`)
}
const branch = environment === 'preview' ? appEnv.TRIGGER_PREVIEW_BRANCH : undefined
if (environment === 'preview' && !branch) {
throw new Error(`${secretId} has no TRIGGER_PREVIEW_BRANCH`)
}

const headers = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(branch ? { 'x-trigger-branch': branch } : {}),
}
const envvarsUrl = `${TRIGGER_API_URL}/api/v1/projects/${project}/envvars`
async function request<T>(url: string, body?: unknown): Promise<T> {
const response = await fetch(url, {
method: body === undefined ? 'GET' : 'POST',
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) throw new Error(`${url} failed with ${response.status}`)
return (await response.json()) as T
}
async function listPlainNames(): Promise<Set<string>> {
const listed = await request<ListedVariable[] | { data: ListedVariable[] }>(
`${envvarsUrl}/${environment}`
)
const variables = Array.isArray(listed) ? listed : listed.data
return new Set(variables.filter((v) => !v.isSecret).map((v) => v.name))
}

const [{ variables: current }, plainNames] = await Promise.all([
request<{ variables: Record<string, string> }>(envvarsUrl),
listPlainNames(),
])

const synced = buildTriggerEnvVars(appEnv)
const syncedNames = new Set(synced.map((v) => v.name))
const { added, changed } = diffTriggerEnv(synced, current)
const toFlag = synced.filter(
(v) => v.isSecret && plainNames.has(v.name) && current[v.name] === v.value
)
const names = (vars: { name: string }[]) => vars.map((v) => v.name).sort()

logger.info('Trigger.dev env sync plan', {
environment,
branch,
secretId,
synced: synced.length,
unchanged: synced.length - added.length - changed.length,
added: names(added),
changed: names(changed),
plainToSecretOnSync: names(changed.filter((v) => v.isSecret && plainNames.has(v.name))),
plainUnchangedNeedingSecretFlag: names(toFlag),
appKeysNotSynced: Object.keys(appEnv)
.filter((name) => !syncedNames.has(name))
.sort(),
})

if (!applySecretFlags || toFlag.length === 0) return
// The import resubmits each value, so re-read right before each batch and skip any variable
// rotated since the plan: flagging must never write back an older value.
const skipped: string[] = []
for (const batch of chunkArray(toFlag, IMPORT_BATCH_SIZE)) {
const { variables: latest } = await request<{ variables: Record<string, string> }>(envvarsUrl)
const unrotated = batch.filter((v) => latest[v.name] === v.value)
skipped.push(...batch.filter((v) => latest[v.name] !== v.value).map((v) => v.name))
if (unrotated.length === 0) continue
await request(`${envvarsUrl}/${environment}/import`, {
variables: Object.fromEntries(unrotated.map((v) => [v.name, v.value])),
override: true,
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
isSecret: true,
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
})
}
const stillPlain = await listPlainNames()
const unflagged = names(toFlag.filter((v) => stillPlain.has(v.name) && !skipped.includes(v.name)))
if (unflagged.length > 0) throw new Error(`Still plain after flagging: ${unflagged.join(', ')}`)
logger.info('Marked unchanged variables as secrets', {
environment,
flagged: toFlag.length - skipped.length,
skippedAsRotated: skipped.sort(),
})
}

main().catch((error) => {
logger.error('Trigger.dev env sync plan failed', { error: getErrorMessage(error) })
process.exit(1)
})
51 changes: 40 additions & 11 deletions apps/sim/trigger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { createLogger } from '@sim/logger'
import {
diffTriggerEnv,
resolveTriggerEnvFromAppSecret,
TRIGGER_CONSTANT_VARS,
} from '@sim/runtime-secrets/trigger-env'
import { distrustCompromisedRoots } from '@sim/security/tls-trust'
import { getErrorMessage } from '@sim/utils/errors'
import type { BuildExtension } from '@trigger.dev/build'
import {
additionalFiles,
Expand Down Expand Up @@ -66,6 +72,35 @@ function getFunctionExecutionEnvVars() {
})
}

/**
* Mirrors the app's env-vars secret into an environment listed in
* `TRIGGER_ENV_SYNC_TARGETS`, logging only counts, never names or values (CI
* logs are public). Exits on failure: `syncEnvVars` catches a thrown error and
* deploys without syncing, and a failed deploy leaves the running version and
* its environment untouched where a silent skip would keep stale configuration.
*/
async function syncFromAppSecret(environment: string, deployedEnv: Record<string, string>) {
const logger = createLogger('TriggerEnvSync')
try {
const { secretId, vars } = await resolveTriggerEnvFromAppSecret(environment)
const { added, changed } = diffTriggerEnv(vars, deployedEnv)
logger.info('Syncing the Trigger.dev environment from the app env-vars secret', {
environment,
secretId,
total: vars.length,
added: added.length,
changed: changed.length,
})
return vars
} catch (error) {
logger.error('Trigger.dev env sync failed; aborting the deploy', {
environment,
error: getErrorMessage(error),
})
process.exit(1)
}
}

/**
* Node.js version copied into the task image, in lockstep with `NODE_VERSION`
* in `docker/app.Dockerfile`: `isolated-vm` 6.x loads its Node 24 (ABI 137)
Expand Down Expand Up @@ -198,17 +233,11 @@ export default defineConfig({
],
extensions: [
nodeBinary(),
syncEnvVars(() => [
{ name: 'DB_APP_NAME', value: 'sim-trigger' },
/**
* Workers run Trigger.dev by definition, but the flag saying so was only
* set on the app container. Syncing it keeps the deployment flag honest
* inside runs; the dispatch decision itself no longer depends on it,
* because the `init` hook above marks the run process directly.
*/
{ name: 'TRIGGER_DEV_ENABLED', value: 'TRUE' },
...getFunctionExecutionEnvVars(),
]),
syncEnvVars(({ environment, env: deployedEnv }) =>
env.TRIGGER_ENV_SYNC_TARGETS?.split(',').includes(environment)
? syncFromAppSecret(environment, deployedEnv)
: [...TRIGGER_CONSTANT_VARS, ...getFunctionExecutionEnvVars()]
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
),
additionalFiles({
files: [
'./lib/execution/isolated-vm-worker.cjs',
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-secrets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./trigger-env": {
"types": "./src/trigger-env.ts",
"default": "./src/trigger-env.ts"
}
},
"scripts": {
Expand Down
28 changes: 21 additions & 7 deletions packages/runtime-secrets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ export async function loadRuntimeSecrets(): Promise<void> {
return
}

const client = new SecretsManagerClient(
process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}
)

const secretString = await fetchSecretString(client, secretId)
const entries = parseSecretJson(secretString)
const entries = await fetchRuntimeSecrets(secretId)

let loaded = 0
let skipped = 0
Expand All @@ -47,13 +42,32 @@ export async function loadRuntimeSecrets(): Promise<void> {
skipped++
continue
}
process.env[key] = typeof value === 'string' ? value : JSON.stringify(value)
process.env[key] = value
loaded++
}

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

/**
* Reads one env-vars JSON secret as the string map a process would see: the
* same values {@link loadRuntimeSecrets} writes into `process.env`, with
* non-string JSON values serialized. Retries transient failures and throws on
* anything else, so a caller never proceeds on a partial or missing secret.
*/
export async function fetchRuntimeSecrets(secretId: string): Promise<Record<string, string>> {
const client = new SecretsManagerClient(
process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}
)
const entries = parseSecretJson(await fetchSecretString(client, secretId))
return Object.fromEntries(
Object.entries(entries).map(([key, value]) => [
key,
typeof value === 'string' ? value : JSON.stringify(value),
])
)
}

async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise<string> {
const response = await sendWithRetry(client, secretId)
if (!response.SecretString) {
Expand Down
Loading
Loading