From 5725f377e9a38bff8e779bcca52a5965e2abd8ea Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:22:13 +0200 Subject: [PATCH 01/14] feat(core): add deployment_bundle artifact type and fromBundle deployment flag Schema groundwork for pre-bundled deploys: the CLI bundles locally and uploads the build context as a deployment_bundle artifact; fromBundle on the initialize request and BuildServerMetadata signals that the build server should skip install/bundle and only run the container build. --- packages/core/src/v3/schemas/api.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 3b57395b297..efd260f062d 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -580,6 +580,7 @@ export const BuildServerMetadata = z.object({ skipPromotion: z.boolean().optional(), configFilePath: z.string().optional(), skipEnqueue: z.boolean().optional(), + fromBundle: z.boolean().optional(), }); export type BuildServerMetadata = z.infer; @@ -646,7 +647,7 @@ export const UpsertBranchResponseBody = z.object({ export type UpsertBranchResponseBody = z.infer; export const CreateArtifactRequestBody = z.object({ - type: z.enum(["deployment_context"]).default("deployment_context"), + type: z.enum(["deployment_context", "deployment_bundle"]).default("deployment_context"), contentType: z.string().default("application/gzip"), contentLength: z.number().optional(), }); @@ -704,6 +705,7 @@ type NativeBuildOutput = BaseOutput & { artifactKey?: string; configFilePath?: string; skipEnqueue?: boolean; + fromBundle?: boolean; }; type NonNativeBuildOutput = BaseOutput & { @@ -712,6 +714,7 @@ type NonNativeBuildOutput = BaseOutput & { artifactKey?: never; configFilePath?: never; skipEnqueue?: never; + fromBundle?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -720,6 +723,9 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase. artifactKey: z.string().optional(), configFilePath: z.string().optional(), skipEnqueue: z.boolean().optional().default(false), + // The uploaded artifact is a pre-built bundle (local install + bundle already done); + // the build server should skip install/bundle and only run the container build. + fromBundle: z.boolean().optional(), }); export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform( @@ -727,7 +733,7 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu if (data.isNativeBuild) { return { ...data, isNativeBuild: true as const }; } - const { skipPromotion, artifactKey, configFilePath, skipEnqueue, ...rest } = data; + const { skipPromotion, artifactKey, configFilePath, skipEnqueue, fromBundle, ...rest } = data; return { ...rest, isNativeBuild: false as const }; } ); From b2367be8a7dfd354b353e256c9b11a5060724c3b Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:28:29 +0200 Subject: [PATCH 02/14] feat(cli): add --local-bundle and --from-bundle deploy modes --local-bundle: bundle locally (install + esbuild, same as the classic path), upload only the resulting build context as a deployment_bundle artifact, and let the build server run just the container build. Env-var sync happens client-side since the build server never sees the unscrubbed manifest. The build-arg values (scrubbed from build.json) travel in trigger-build-args.json, excluded from the image COPY context via a generated .dockerignore. --from-bundle (hidden): build the deployment image from a pre-built bundle directory, skipping config loading and bundling entirely, used by the build server's nested deploy for pre-bundled artifacts. Reuses the extracted buildAndFinalizeDeployment tail shared with the classic path. Bundle archives use a dedicated archiver: the source-context ignores (dist, build, .trigger) would strip the bundle output itself. --- packages/cli-v3/src/commands/deploy.ts | 388 ++++++++++++++++++-- packages/cli-v3/src/deploy/bundleArchive.ts | 45 +++ 2 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 packages/cli-v3/src/deploy/bundleArchive.ts diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 0c6eece0a54..839ef756e39 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -7,7 +7,7 @@ import type { DeploymentFinalizedEvent, DeploymentTriggeredVia, } from "@trigger.dev/core/v3/schemas"; -import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; +import { BuildManifest, DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; import type { Command } from "commander"; import { Option as CommandOption } from "commander"; import { join, relative, resolve } from "node:path"; @@ -19,8 +19,9 @@ import type { CliApiClient } from "../apiClient.js"; import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; +import { createBundleArchive } from "../deploy/bundleArchive.js"; import { S2 } from "@s2-dev/streamstore"; -import { mkdir, readFile, unlink } from "node:fs/promises"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { CommonCommandOptions, commonOptions, @@ -48,7 +49,7 @@ import { prettyWarning, } from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; -import { isDirectory } from "../utilities/fileSystem.js"; +import { isDirectory, writeJSONFile } from "../utilities/fileSystem.js"; import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; import { createGitMeta, isGitHubActions } from "../utilities/gitMeta.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; @@ -82,6 +83,8 @@ const DeployCommandOptions = CommonCommandOptions.extend({ push: z.boolean().optional(), builder: z.string().default("trigger"), nativeBuildServer: z.boolean().default(false), + localBundle: z.boolean().default(false), + fromBundle: z.string().optional(), detach: z.boolean().default(false), plain: z.boolean().default(false), compression: z.enum(["zstd", "gzip"]).default("zstd"), @@ -94,6 +97,13 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; +// Carries the build-arg VALUES for the `ARG` lines in the generated Containerfile. +// They only exist in the in-memory build manifest (build.json is deliberately scrubbed +// because it gets COPY'd into the image), so --local-bundle writes them to this file +// and --from-bundle reads them back. A .dockerignore entry keeps the file out of the +// image COPY context so the values never land in image layers. +const BUNDLE_BUILD_ARGS_FILE = "trigger-build-args.json"; + export function configureDeployCommand(program: Command) { return ( commonOptions( @@ -232,6 +242,23 @@ export function configureDeployCommand(program: Command) { "Use the native build server for building the image" ) ) + .addOption( + new CommandOption( + "--local-bundle", + "Experimental: bundle the project locally and upload only the build output; the build server runs the container build. Useful when the remote install/bundle step doesn't work for your project setup. Implies using the native build server." + ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild"]) + ) + .addOption( + new CommandOption( + "--from-bundle ", + "Internal: build the deployment image from a pre-built bundle directory, skipping the bundling step. Implies a local build." + ) + .implies({ localBuild: true }) + .conflicts(["nativeBuildServer", "localBundle"]) + .hideHelp() + ) .addOption( new CommandOption( "--detach", @@ -298,6 +325,21 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Using project ref from env", { ref: envVars.TRIGGER_PROJECT_REF }); } + if (options.fromBundle) { + // Builds the image from a pre-built bundle directory. The bundle carries no + // trigger.config.ts source, so this path skips config loading entirely and + // drives off the bundle's build.json + the deployment record. + await handleFromBundleDeploy({ + bundleDir: options.fromBundle, + options, + dashboardUrl: authorization.dashboardUrl, + auth: authorization.auth, + existingDeploymentId: envVars.TRIGGER_EXISTING_DEPLOYMENT_ID, + projectRefOverride: options.projectRef ?? envVars.TRIGGER_PROJECT_REF, + }); + return; + } + const resolvedConfig = await loadConfig({ cwd: projectPath, overrides: { project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF }, @@ -370,6 +412,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { options, userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined, gitMeta, + branch, }); return; } @@ -439,8 +482,6 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { // which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud // and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion. const isLocalBuild = options.localBuild || !deployment.externalBuildData; - const authenticateToTriggerRegistry = options.localBuild; - const skipServerSideRegistryPush = options.localBuild; // Fail fast if we know local builds will fail if (isLocalBuild) { @@ -508,12 +549,57 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } + await buildAndFinalizeDeployment({ + apiClient: projectClient.client, + projectId: projectClient.id, + projectRef: resolvedConfig.project, + deployment, + options, + dashboardUrl: authorization.dashboardUrl, + authAccessToken: authorization.auth.accessToken, + compilationPath: destination.path, + buildEnvVars: buildManifest.build.env, + branch, + isLocalBuild, + }); +} + +// The shared "build the image and finalize the deployment" tail, used by the standard +// deploy path (after bundling) and by --from-bundle (building from a pre-built bundle). +async function buildAndFinalizeDeployment({ + apiClient, + projectId, + projectRef, + deployment, + options, + dashboardUrl, + authAccessToken, + compilationPath, + buildEnvVars, + branch, + isLocalBuild, +}: { + apiClient: CliApiClient; + projectId: string; + projectRef: string; + deployment: Deployment; + options: DeployCommandOptions; + dashboardUrl: string; + authAccessToken: string; + compilationPath: string; + buildEnvVars: Record | undefined; + branch: string | undefined; + isLocalBuild: boolean; +}) { + const authenticateToTriggerRegistry = options.localBuild; + const skipServerSideRegistryPush = options.localBuild; + const version = deployment.version; - const rawDeploymentLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`; + const rawTestLink = `${dashboardUrl}/projects/v3/${projectRef}/test?environment=${ + options.env === "prod" ? "prod" : "stg" + }`; const deploymentLink = cliLink("View deployment", rawDeploymentLink); const testLink = cliLink("Test tasks", rawTestLink); @@ -550,15 +636,15 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { externalBuildId: deployment.externalBuildData?.buildId, externalBuildToken: deployment.externalBuildData?.buildToken, externalBuildProjectId: deployment.externalBuildData?.projectId, - projectId: projectClient.id, - projectRef: resolvedConfig.project, - apiUrl: projectClient.client.apiURL, - apiKey: projectClient.client.accessToken!, - apiClient: projectClient.client, + projectId, + projectRef, + apiUrl: apiClient.apiURL, + apiKey: apiClient.accessToken!, + apiClient, branchName: branch, - authAccessToken: authorization.auth.accessToken, - compilationPath: destination.path, - buildEnvVars: buildManifest.build.env, + authAccessToken, + compilationPath, + buildEnvVars, compression: options.compression, cacheCompression: options.cacheCompression, compressionLevel: options.compressionLevel, @@ -593,7 +679,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const buildFailed = !warnings.ok || !buildResult.ok; if (buildFailed && canShowLocalBuildHint) { - const providerStatus = await projectClient.client.getRemoteBuildProviderStatus(); + const providerStatus = await apiClient.getRemoteBuildProviderStatus(); if (providerStatus.success && providerStatus.data.status === "degraded") { prettyWarning(providerStatus.data.message + "\n"); @@ -602,7 +688,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!warnings.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: warnings.summary }, buildResult.logs, @@ -616,7 +702,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!buildResult.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: buildResult.error }, buildResult.logs, @@ -627,11 +713,11 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { throw new SkipLoggingError("Failed to build image"); } - const getDeploymentResponse = await projectClient.client.getDeployment(deployment.id); + const getDeploymentResponse = await apiClient.getDeployment(deployment.id); if (!getDeploymentResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", message: getDeploymentResponse.error }, buildResult.logs, @@ -649,7 +735,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { : undefined; await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", @@ -674,7 +760,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } - const finalizeResponse = await projectClient.client.finalizeDeployment( + const finalizeResponse = await apiClient.finalizeDeployment( deployment.id, { imageDigest: buildResult.digest, @@ -699,7 +785,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!finalizeResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "FinalizeError", message: finalizeResponse.error }, buildResult.logs, @@ -754,18 +840,18 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { TRIGGER_DEPLOYMENT_VERSION: version, TRIGGER_VERSION: version, TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, - TRIGGER_DEPLOYMENT_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project + TRIGGER_DEPLOYMENT_URL: `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`, + TRIGGER_TEST_URL: `${dashboardUrl}/projects/v3/${ + projectRef }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, }, outputs: { deploymentVersion: version, workerVersion: version, deploymentShortCode: deployment.shortCode, - deploymentUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - testUrl: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project + deploymentUrl: `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`, + testUrl: `${dashboardUrl}/projects/v3/${ + projectRef }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, needsPromotion: options.skipPromotion ? "true" : "false", }, @@ -1028,6 +1114,7 @@ async function handleNativeBuildServerDeploy({ dashboardUrl, userId, gitMeta, + branch, }: { apiClient: CliApiClient; config: Awaited>; @@ -1035,23 +1122,85 @@ async function handleNativeBuildServerDeploy({ options: DeployCommandOptions; userId?: string; gitMeta?: GitMeta; + branch?: string; }) { const tmpDir = join(config.workingDir, ".trigger", "tmp"); await mkdir(tmpDir, { recursive: true }); const archivePath = join(tmpDir, `deploy-${Date.now()}.tar.gz`); + // In --local-bundle mode, install + bundling happen locally (same as the classic + // non-native path) and only the resulting build context is uploaded; the build + // server then runs just the container build from it. + let bundleManifest: BuildManifest | undefined; + let bundleOutputPath: string | undefined; + + if (options.localBundle) { + const serverEnvVars = await apiClient.getEnvironmentVariables(config.project); + loadDotEnvVars(config.workingDir, options.envFile); + + const destination = getTmpDir(config.workingDir, "build", false); + const forcedExternals = await resolveAlwaysExternal(apiClient); + + const $buildSpinner = spinner({ plain: options.plain }); + + const [buildError, buildManifest] = await tryCatch( + buildWorker({ + target: "deploy", + environment: options.env, + branch, + destination: destination.path, + resolvedConfig: config, + rewritePaths: true, + envVars: serverEnvVars.success ? serverEnvVars.data.variables : {}, + forcedExternals, + plain: options.plain, + listener: { + onBundleStart() { + $buildSpinner.start("Building trigger code"); + }, + onBundleComplete(result) { + $buildSpinner.stop("Successfully built code"); + logger.debug("Bundle result", result); + }, + }, + }) + ); + + if (buildError) { + $buildSpinner.stop("Failed to build code"); + throw buildError; + } + + bundleManifest = buildManifest; + bundleOutputPath = destination.path; + + // Persist the build-arg values (scrubbed from build.json) for the build server's + // --from-bundle step, and keep them out of the image via .dockerignore. + await writeJSONFile(join(destination.path, BUNDLE_BUILD_ARGS_FILE), { + env: buildManifest.build.env ?? {}, + }); + await writeFile( + join(destination.path, ".dockerignore"), + `${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n` + ); + } + const $deploymentSpinner = spinner(); $deploymentSpinner.start("Preparing deployment files"); - await createContextArchive(config.workspaceDir, archivePath); + if (bundleOutputPath) { + await createBundleArchive(bundleOutputPath, archivePath); + } else { + await createContextArchive(config.workspaceDir, archivePath); + } const archiveSize = await getArchiveSize(archivePath); const sizeMB = (archiveSize / 1024 / 1024).toFixed(2); $deploymentSpinner.message(`Deployment files ready (${sizeMB} MB)`); const artifactResult = await apiClient.createArtifact({ - type: "deployment_context", + type: options.localBundle ? "deployment_bundle" : "deployment_context", contentType: "application/gzip", contentLength: archiveSize, }); @@ -1117,16 +1266,17 @@ async function handleNativeBuildServerDeploy({ : undefined; const initializeDeploymentResult = await apiClient.initializeDeployment({ - contentHash: "-", + contentHash: bundleManifest?.contentHash ?? "-", userId, gitMeta, type: config.features.run_engine_v2 ? "MANAGED" : "V1", - runtime: config.runtime, + runtime: bundleManifest?.runtime ?? config.runtime, isNativeBuild: true, artifactKey, skipPromotion: options.skipPromotion, configFilePath, triggeredVia: getTriggeredVia(), + fromBundle: options.localBundle ? true : undefined, }); if (!initializeDeploymentResult.success) { @@ -1137,6 +1287,50 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; + // In --local-bundle mode the build server never runs install/bundle, so the env-var + // sync that extensions rely on (syncEnvVars) must happen here on the client, using + // the unscrubbed in-memory manifest — same semantics as the classic local path. + if (bundleManifest && !options.skipSyncEnvVars) { + const childVars = bundleManifest.deploy.sync?.env ?? {}; + const parentVars = bundleManifest.deploy.sync?.parentEnv ?? {}; + const secretChildVars = bundleManifest.deploy.sync?.secretEnv ?? {}; + const secretParentVars = bundleManifest.deploy.sync?.secretParentEnv ?? {}; + + const hasVarsToSync = + Object.keys(childVars).length > 0 || + Object.keys(secretChildVars).length > 0 || + // Only sync parent variables if this is a branch environment + (branch && (Object.keys(parentVars).length > 0 || Object.keys(secretParentVars).length > 0)); + + if (hasVarsToSync) { + const uploadResult = await syncEnvVarsWithServer( + apiClient, + config.project, + options.env, + childVars, + parentVars, + secretChildVars, + secretParentVars + ); + + if (!uploadResult.success) { + $deploymentSpinner.stop("Failed to sync env vars"); + log.error(chalk.bold(chalkError(`Failed to sync env vars: ${uploadResult.error}`))); + + await apiClient.failDeployment(deployment.id, { + error: { + name: "SyncEnvVarsError", + message: `Failed to sync env vars with the server: ${uploadResult.error}`, + }, + }); + + throw new OutroCommandError(`Deployment failed`); + } + + logger.debug("Synced env vars with the server"); + } + } + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ options.env === "prod" ? "prod" : "stg" @@ -1448,3 +1642,127 @@ export function verifyDirectory(dir: string, projectPath: string) { throw new Error(`Directory "${dir}" not found at ${projectPath}`); } } + +// Builds and finalizes a deployment from a pre-built bundle directory (the output of +// the bundling step, as produced by --local-bundle / a dry-run build). Used primarily +// by the build server to run ONLY the container build for pre-bundled artifacts, but +// also works standalone for local testing. Skips config loading entirely — the bundle +// has no trigger.config.ts source; everything needed comes from the bundle's build.json, +// the build-args file, and the deployment record. +async function handleFromBundleDeploy({ + bundleDir, + options, + dashboardUrl, + auth, + existingDeploymentId, + projectRefOverride, +}: { + bundleDir: string; + options: DeployCommandOptions; + dashboardUrl: string; + auth: { accessToken: string; apiUrl: string }; + existingDeploymentId?: string; + projectRefOverride?: string; +}) { + const bundlePath = resolve(process.cwd(), bundleDir); + + if (!isDirectory(bundlePath)) { + throw new Error(`Bundle directory not found at ${bundlePath}`); + } + + const [manifestReadError, manifestRaw] = await tryCatch( + readFile(join(bundlePath, "build.json"), "utf-8") + ); + + if (manifestReadError) { + throw new Error( + `Failed to read build.json in the bundle directory: ${manifestReadError.message}` + ); + } + + const manifestResult = BuildManifest.safeParse(JSON.parse(manifestRaw)); + + if (!manifestResult.success) { + throw new Error(`Invalid build.json in the bundle directory: ${manifestResult.error.message}`); + } + + const bundleManifest = manifestResult.data; + + // Recover the build-arg values scrubbed from build.json (written by --local-bundle). + // Optional: bundles without build-time env vars may not carry the file. + let buildEnvVars: Record | undefined; + const [buildArgsError, buildArgsRaw] = await tryCatch( + readFile(join(bundlePath, BUNDLE_BUILD_ARGS_FILE), "utf-8") + ); + + if (!buildArgsError) { + const [parseError, parsed] = await tryCatch(Promise.resolve(JSON.parse(buildArgsRaw))); + if (parseError) { + throw new Error(`Invalid ${BUNDLE_BUILD_ARGS_FILE} in the bundle directory`); + } + buildEnvVars = parsed.env ?? {}; + } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { + // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. + buildEnvVars = bundleManifest.build.env; + } + + const projectRef = projectRefOverride ?? bundleManifest.config.project; + + const branch = options.env === "preview" ? getBranch({ specified: options.branch }) : undefined; + + if (options.env === "preview" && !branch) { + throw new Error( + "Preview deploys from a bundle require an explicit branch. Pass --branch ." + ); + } + + const projectClient = await getProjectClient({ + accessToken: auth.accessToken, + apiUrl: auth.apiUrl, + projectRef, + env: options.env, + branch, + profile: options.profile, + }); + + if (!projectClient) { + throw new Error("Failed to get project client"); + } + + const deployment = await initializeOrAttachDeployment( + projectClient.client, + { + contentHash: bundleManifest.contentHash, + type: "MANAGED", + runtime: bundleManifest.runtime, + isLocalBuild: true, + isNativeBuild: false, + triggeredVia: getTriggeredVia(), + }, + existingDeploymentId + ); + + // Fail fast if we know local builds will fail + const buildxResult = await x("docker", ["buildx", "version"]); + + if (buildxResult.exitCode !== 0) { + logger.debug(`"docker buildx version" failed (${buildxResult.exitCode}):`, buildxResult); + throw new Error( + "Failed to find docker buildx. Please install it: https://github.com/docker/buildx#installing." + ); + } + + await buildAndFinalizeDeployment({ + apiClient: projectClient.client, + projectId: projectClient.id, + projectRef, + deployment, + options, + dashboardUrl, + authAccessToken: auth.accessToken, + compilationPath: bundlePath, + buildEnvVars, + branch, + isLocalBuild: true, + }); +} diff --git a/packages/cli-v3/src/deploy/bundleArchive.ts b/packages/cli-v3/src/deploy/bundleArchive.ts new file mode 100644 index 00000000000..ffb00c2ae90 --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.ts @@ -0,0 +1,45 @@ +import { glob } from "tinyglobby"; +import * as tar from "tar"; +import { logger } from "../utilities/logger.js"; + +// The bundle dir is generated build output (bundled JS, synthesized package.json, +// build.json, Containerfile, .trigger/skills). Unlike the source-context archiver, +// it must NOT apply the usual build-output ignores (dist, build, .trigger) — those +// would strip the bundle itself. Only genuinely unwanted entries are excluded. +const BUNDLE_IGNORES = ["**/node_modules", "**/.DS_Store"]; + +/** + * Archives a pre-built bundle directory (the buildWorker destination) so its + * contents land at the archive root — the build server extracts without + * stripping path components. + */ +export async function createBundleArchive(bundleDir: string, outputPath: string) { + logger.debug("Creating bundle archive", { bundleDir, outputPath }); + + const files = await glob(["**/*"], { + cwd: bundleDir, + ignore: BUNDLE_IGNORES, + dot: true, // .trigger/skills and .dockerignore must be included + absolute: false, + onlyFiles: true, + followSymbolicLinks: false, + }); + + if (files.length === 0) { + throw new Error("No files found in the bundle output. This is likely a bug."); + } + + await tar.create( + { + gzip: true, + file: outputPath, + cwd: bundleDir, + portable: true, + preservePaths: false, + mtime: new Date(0), + }, + files + ); + + logger.debug("Bundle archive created", { outputPath, fileCount: files.length }); +} From 6ab879d4d092638629311820d97dea54d59b037f Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:30:20 +0200 Subject: [PATCH 03/14] feat(webapp): accept deployment_bundle artifacts and thread fromBundle to build enqueue The artifacts endpoint accepts the new bundle type (same key prefix and size limit as deployment contexts), and initializeDeployment persists fromBundle in the build server metadata and passes it through the enqueue-build options so the build job can skip install/bundle. --- .changeset/local-bundle-deploy.md | 6 ++++++ apps/webapp/app/routes/api.v1.artifacts.ts | 3 +++ apps/webapp/app/services/platform.v3.server.ts | 1 + apps/webapp/app/v3/services/artifacts.server.ts | 4 +++- apps/webapp/app/v3/services/deployment.server.ts | 1 + apps/webapp/app/v3/services/initializeDeployment.server.ts | 2 ++ 6 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/local-bundle-deploy.md diff --git a/.changeset/local-bundle-deploy.md b/.changeset/local-bundle-deploy.md new file mode 100644 index 00000000000..b6f88cda6bb --- /dev/null +++ b/.changeset/local-bundle-deploy.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add an experimental `--local-bundle` deploy flag: your project is installed and bundled locally (like classic deploys) and only the build output is uploaded, while the image is still built remotely. Useful when the remote build's install step doesn't work for your project setup. diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index c74c66a222d..2258edb9518 100644 --- a/apps/webapp/app/routes/api.v1.artifacts.ts +++ b/apps/webapp/app/routes/api.v1.artifacts.ts @@ -62,6 +62,9 @@ export async function action({ request }: ActionFunctionArgs) { case "deployment_context": errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`; break; + case "deployment_bundle": + errorMessage = `Bundle size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Reach out to us if you are seeing this error consistently.`; + break; default: body.data.type satisfies never; errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`; diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bded0b92065..a49de327cc9 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -996,6 +996,7 @@ export async function enqueueBuild( options: { skipPromotion?: boolean; configFilePath?: string; + fromBundle?: boolean; } ) { if (!client) return undefined; diff --git a/apps/webapp/app/v3/services/artifacts.server.ts b/apps/webapp/app/v3/services/artifacts.server.ts index 9e82af51234..7a06f1cac6a 100644 --- a/apps/webapp/app/v3/services/artifacts.server.ts +++ b/apps/webapp/app/v3/services/artifacts.server.ts @@ -24,16 +24,18 @@ const objectStoreClient = const artifactKeyPrefixByType = { deployment_context: "deployments", + deployment_bundle: "deployments", } as const; const artifactBytesSizeLimitByType = { deployment_context: 100 * 1024 * 1024, // 100MB + deployment_bundle: 100 * 1024 * 1024, // 100MB } as const; export class ArtifactsService extends BaseService { private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET; public createArtifact( - type: "deployment_context", + type: "deployment_context" | "deployment_bundle", authenticatedEnv: AuthenticatedEnvironment, contentLength?: number ) { diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index f726bba3d6d..006894a695c 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -339,6 +339,7 @@ export class DeploymentService extends BaseService { options: { skipPromotion?: boolean; configFilePath?: string; + fromBundle?: boolean; } ) { return fromPromise( diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index abb99082dd6..eb9db2ac20a 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -183,6 +183,7 @@ export class InitializeDeploymentService extends BaseService { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, skipEnqueue: payload.skipEnqueue, + fromBundle: payload.fromBundle, } : {}), } @@ -276,6 +277,7 @@ export class InitializeDeploymentService extends BaseService { .enqueueBuild(environment, deployment, payload.artifactKey, { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, + fromBundle: payload.fromBundle, }) .orElse((error) => { logger.error("Failed to enqueue build", { From 26353da204dc748392b59a6fe96ee4e3b47d498e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:50:25 +0200 Subject: [PATCH 04/14] fix(cli): review fixes for the local-bundle paths - --local-bundle now respects --dry-run (build the bundle, print its path, stop before any upload or deployment initialization) - append to an existing .dockerignore in the bundle output instead of clobbering one a build extension may have written - send config.runtime on initialization, identical to classic native deploys, instead of the resolved manifest runtime - bundle artifacts get a distinct 'bundles/' key prefix so the build server can recognize a bundle even if the fromBundle flag is stripped by schema skew along the enqueue chain --- .../app/v3/services/artifacts.server.ts | 5 +++- packages/cli-v3/src/commands/deploy.ts | 24 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/v3/services/artifacts.server.ts b/apps/webapp/app/v3/services/artifacts.server.ts index 7a06f1cac6a..fa5996a247a 100644 --- a/apps/webapp/app/v3/services/artifacts.server.ts +++ b/apps/webapp/app/v3/services/artifacts.server.ts @@ -24,7 +24,10 @@ const objectStoreClient = const artifactKeyPrefixByType = { deployment_context: "deployments", - deployment_bundle: "deployments", + // Distinct prefix on purpose: the artifact key is the one signal that survives + // any schema skew, so the build server can recognize a bundle even if the + // fromBundle flag gets stripped somewhere along the enqueue chain. + deployment_bundle: "bundles", } as const; const artifactBytesSizeLimitByType = { deployment_context: 100 * 1024 * 1024, // 100MB diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 839ef756e39..7ad544a5abe 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1180,10 +1180,24 @@ async function handleNativeBuildServerDeploy({ await writeJSONFile(join(destination.path, BUNDLE_BUILD_ARGS_FILE), { env: buildManifest.build.env ?? {}, }); - await writeFile( - join(destination.path, ".dockerignore"), - `${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n` + + // Append to a .dockerignore a build extension may have produced, never clobber it + const dockerignorePath = join(destination.path, ".dockerignore"); + const [, existingDockerignore] = await tryCatch(readFile(dockerignorePath, "utf-8")); + const dockerignoreEntries = [BUNDLE_BUILD_ARGS_FILE, ".dockerignore"].filter( + (entry) => !existingDockerignore?.split("\n").includes(entry) ); + if (dockerignoreEntries.length > 0) { + await writeFile( + dockerignorePath, + `${existingDockerignore ? existingDockerignore.trimEnd() + "\n" : ""}${dockerignoreEntries.join("\n")}\n` + ); + } + + if (options.dryRun) { + logger.info(`Dry run complete. View the built bundle at ${destination.path}`); + return; + } } const $deploymentSpinner = spinner(); @@ -1270,7 +1284,9 @@ async function handleNativeBuildServerDeploy({ userId, gitMeta, type: config.features.run_engine_v2 ? "MANAGED" : "V1", - runtime: bundleManifest?.runtime ?? config.runtime, + // Deliberately config.runtime (not the resolved manifest runtime) so the persisted + // value is identical to classic native deploys. + runtime: config.runtime, isNativeBuild: true, artifactKey, skipPromotion: options.skipPromotion, From 89f06279b5c282b28fa9e669fc55355ddd2d9449 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 19:11:48 +0200 Subject: [PATCH 05/14] fix(cli): round-2 review refinements for local-bundle - sync env vars BEFORE initializing the deployment: initialization enqueues the remote build synchronously, so a post-init sync raced a fast build, a run triggered right after promotion could execute without the synced vars - keep the bundle dir on --dry-run so the printed path is inspectable - always append the build-args exclusions as the LAST .dockerignore lines so a pre-existing negation cannot re-include them - warn when --from-bundle initializes a fresh deployment (attach mode is the supported flow) --- packages/cli-v3/src/commands/deploy.ts | 110 +++++++++++++------------ 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 7ad544a5abe..bfe84d1ac61 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1139,7 +1139,8 @@ async function handleNativeBuildServerDeploy({ const serverEnvVars = await apiClient.getEnvironmentVariables(config.project); loadDotEnvVars(config.workingDir, options.envFile); - const destination = getTmpDir(config.workingDir, "build", false); + // Keep the bundle dir around on dry runs so the printed path is inspectable + const destination = getTmpDir(config.workingDir, "build", options.dryRun); const forcedExternals = await resolveAlwaysExternal(apiClient); const $buildSpinner = spinner({ plain: options.plain }); @@ -1181,23 +1182,58 @@ async function handleNativeBuildServerDeploy({ env: buildManifest.build.env ?? {}, }); - // Append to a .dockerignore a build extension may have produced, never clobber it + // Append to a .dockerignore a build extension may have produced, never clobber it. + // Our exclusions always go LAST so a pre-existing negation (!file) can't re-include + // the build-args file into the image context. const dockerignorePath = join(destination.path, ".dockerignore"); const [, existingDockerignore] = await tryCatch(readFile(dockerignorePath, "utf-8")); - const dockerignoreEntries = [BUNDLE_BUILD_ARGS_FILE, ".dockerignore"].filter( - (entry) => !existingDockerignore?.split("\n").includes(entry) + await writeFile( + dockerignorePath, + `${ + existingDockerignore ? existingDockerignore.trimEnd() + "\n" : "" + }${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n` ); - if (dockerignoreEntries.length > 0) { - await writeFile( - dockerignorePath, - `${existingDockerignore ? existingDockerignore.trimEnd() + "\n" : ""}${dockerignoreEntries.join("\n")}\n` - ); - } if (options.dryRun) { logger.info(`Dry run complete. View the built bundle at ${destination.path}`); return; } + + // Sync env vars BEFORE initializing the deployment: initialization enqueues the + // remote build synchronously, so syncing afterwards would race a fast build — + // a run triggered right after promotion could execute without the synced vars. + // Syncing is environment-scoped and needs no deployment, so pre-init is safe. + if (!options.skipSyncEnvVars) { + const childVars = buildManifest.deploy.sync?.env ?? {}; + const parentVars = buildManifest.deploy.sync?.parentEnv ?? {}; + const secretChildVars = buildManifest.deploy.sync?.secretEnv ?? {}; + const secretParentVars = buildManifest.deploy.sync?.secretParentEnv ?? {}; + + const hasVarsToSync = + Object.keys(childVars).length > 0 || + Object.keys(secretChildVars).length > 0 || + // Only sync parent variables if this is a branch environment + (branch && + (Object.keys(parentVars).length > 0 || Object.keys(secretParentVars).length > 0)); + + if (hasVarsToSync) { + const uploadResult = await syncEnvVarsWithServer( + apiClient, + config.project, + options.env, + childVars, + parentVars, + secretChildVars, + secretParentVars + ); + + if (!uploadResult.success) { + throw new Error(`Failed to sync env vars with the server: ${uploadResult.error}`); + } + + logger.debug("Synced env vars with the server"); + } + } } const $deploymentSpinner = spinner(); @@ -1303,50 +1339,6 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; - // In --local-bundle mode the build server never runs install/bundle, so the env-var - // sync that extensions rely on (syncEnvVars) must happen here on the client, using - // the unscrubbed in-memory manifest — same semantics as the classic local path. - if (bundleManifest && !options.skipSyncEnvVars) { - const childVars = bundleManifest.deploy.sync?.env ?? {}; - const parentVars = bundleManifest.deploy.sync?.parentEnv ?? {}; - const secretChildVars = bundleManifest.deploy.sync?.secretEnv ?? {}; - const secretParentVars = bundleManifest.deploy.sync?.secretParentEnv ?? {}; - - const hasVarsToSync = - Object.keys(childVars).length > 0 || - Object.keys(secretChildVars).length > 0 || - // Only sync parent variables if this is a branch environment - (branch && (Object.keys(parentVars).length > 0 || Object.keys(secretParentVars).length > 0)); - - if (hasVarsToSync) { - const uploadResult = await syncEnvVarsWithServer( - apiClient, - config.project, - options.env, - childVars, - parentVars, - secretChildVars, - secretParentVars - ); - - if (!uploadResult.success) { - $deploymentSpinner.stop("Failed to sync env vars"); - log.error(chalk.bold(chalkError(`Failed to sync env vars: ${uploadResult.error}`))); - - await apiClient.failDeployment(deployment.id, { - error: { - name: "SyncEnvVarsError", - message: `Failed to sync env vars with the server: ${uploadResult.error}`, - }, - }); - - throw new OutroCommandError(`Deployment failed`); - } - - logger.debug("Synced env vars with the server"); - } - } - const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ options.env === "prod" ? "prod" : "stg" @@ -1745,6 +1737,16 @@ async function handleFromBundleDeploy({ throw new Error("Failed to get project client"); } + if (!existingDeploymentId) { + // The supported flow is attach mode (the build server sets + // TRIGGER_EXISTING_DEPLOYMENT_ID). Fresh-init from a bundle is equivalent to a + // plain local build and mainly useful for local testing — warn so nobody relies + // on it against cloud by accident. + logger.warn( + "No existing deployment to attach to — initializing a fresh local-build deployment from the bundle. This path is intended for testing." + ); + } + const deployment = await initializeOrAttachDeployment( projectClient.client, { From a37fc67f8f3fadac91a4113b1a46621ad065b397 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 19:21:47 +0200 Subject: [PATCH 06/14] fix(webapp): allow host.docker.internal on the Vite dev server Local docker builds and the dev build-server harness reach the dev webapp as host.docker.internal (e.g. the in-build indexer fetching env vars); Vite's default host blocking rejected those requests since the Vite migration. --- apps/webapp/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index b008ae04ed0..c075c786bc6 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -34,6 +34,9 @@ export default defineConfig({ clientFiles: ["./app/entry.client.tsx", "./app/root.tsx", "./app/components/**/*.tsx"], ssrFiles: ["./app/entry.server.tsx", "./app/root.tsx"], }, + // Local docker builds (and the dev build-server harness) reach the dev webapp as + // host.docker.internal — e.g. the in-build indexer fetching env vars. + allowedHosts: ["host.docker.internal"], }, build: { sourcemap: true, From 4e9f1509d6e4af7ca3d4daa069991a0961cc1a35 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 21:33:23 +0200 Subject: [PATCH 07/14] fix(cli): warn when build-tuning flags are ignored with --local-bundle The container build runs on the build server with fixed settings, so local build-tuning flags (--compression, --no-cache, --builder, --network, --push, --load, ...) are parsed but not forwarded. Warn instead of silently dropping them, depot honored these flags, so local-bundle users migrating from depot would otherwise assume they took effect. --- packages/cli-v3/src/commands/deploy.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index bfe84d1ac61..c3cc92eece1 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1136,6 +1136,26 @@ async function handleNativeBuildServerDeploy({ let bundleOutputPath: string | undefined; if (options.localBundle) { + // The container build runs on the build server with its own fixed settings — + // local build-tuning flags are not forwarded. Be honest about ignoring them. + const ignoredBuildFlags = [ + options.compression !== "zstd" && "--compression", + options.cacheCompression !== "zstd" && "--cache-compression", + options.compressionLevel !== undefined && "--compression-level", + !options.forceCompression && "--no-force-compression", + !options.cache && "--no-cache", + options.builder !== "trigger" && "--builder", + options.network !== undefined && "--network", + options.push !== undefined && "--push/--no-push", + options.load !== undefined && "--load/--no-load", + ].filter((flag): flag is string => Boolean(flag)); + + if (ignoredBuildFlags.length > 0) { + log.warn( + `The following flags are ignored with --local-bundle (the image is built remotely): ${ignoredBuildFlags.join(", ")}` + ); + } + const serverEnvVars = await apiClient.getEnvironmentVariables(config.project); loadDotEnvVars(config.workingDir, options.envFile); From 864dba08177b032655445f506fc137f01a181593 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 22:45:38 +0200 Subject: [PATCH 08/14] test(cli): unit tests for the bundle archiver Covers the cross-path contract: contents at archive root (extracted without stripping), dotfiles and nested .trigger/skills included, node_modules/.DS_Store excluded, dist-like names NOT excluded (the bundle is build output), empty-dir error. --- .../cli-v3/src/deploy/bundleArchive.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/cli-v3/src/deploy/bundleArchive.test.ts diff --git a/packages/cli-v3/src/deploy/bundleArchive.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts new file mode 100644 index 00000000000..dc6a0af36d7 --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createBundleArchive } from "./bundleArchive.js"; + +describe("createBundleArchive", () => { + let bundleDir: string; + let outDir: string; + + beforeEach(async () => { + bundleDir = await mkdtemp(join(tmpdir(), "bundle-src-")); + outDir = await mkdtemp(join(tmpdir(), "bundle-out-")); + }); + + afterEach(async () => { + await rm(bundleDir, { recursive: true, force: true }); + await rm(outDir, { recursive: true, force: true }); + }); + + it("archives bundle contents at the root, including dotfiles and nested dirs", async () => { + // Shape of a real buildWorker output dir + await writeFile(join(bundleDir, "build.json"), JSON.stringify({ contentHash: "abc" })); + await writeFile(join(bundleDir, "Containerfile"), "FROM scratch"); + await writeFile(join(bundleDir, "package.json"), "{}"); + await writeFile(join(bundleDir, "index.mjs"), "export {}"); + await writeFile(join(bundleDir, ".dockerignore"), "trigger-build-args.json\n"); + await writeFile(join(bundleDir, "trigger-build-args.json"), JSON.stringify({ env: {} })); + await mkdir(join(bundleDir, ".trigger", "skills", "my-skill"), { recursive: true }); + await writeFile(join(bundleDir, ".trigger", "skills", "my-skill", "SKILL.md"), "# skill"); + + const archivePath = join(outDir, "bundle.tar.gz"); + await createBundleArchive(bundleDir, archivePath); + + const extractDir = join(outDir, "extracted"); + await mkdir(extractDir); + // The build server extracts WITHOUT stripping path components — the contract + // is that bundle contents live at the archive root. + await tar.extract({ file: archivePath, cwd: extractDir }); + + const rootEntries = (await readdir(extractDir)).sort(); + expect(rootEntries).toEqual( + [ + ".dockerignore", + ".trigger", + "Containerfile", + "build.json", + "index.mjs", + "package.json", + "trigger-build-args.json", + ].sort() + ); + + // Nested dot-dir contents survive + const skill = await readFile( + join(extractDir, ".trigger", "skills", "my-skill", "SKILL.md"), + "utf-8" + ); + expect(skill).toBe("# skill"); + }); + + it("excludes node_modules and .DS_Store but nothing else", async () => { + await writeFile(join(bundleDir, "build.json"), "{}"); + await writeFile(join(bundleDir, ".DS_Store"), "junk"); + await mkdir(join(bundleDir, "node_modules", "leftover"), { recursive: true }); + await writeFile(join(bundleDir, "node_modules", "leftover", "index.js"), "x"); + // dist-like names must NOT be excluded — the bundle IS build output + await mkdir(join(bundleDir, "dist"), { recursive: true }); + await writeFile(join(bundleDir, "dist", "chunk.mjs"), "x"); + + const archivePath = join(outDir, "bundle.tar.gz"); + await createBundleArchive(bundleDir, archivePath); + + const extractDir = join(outDir, "extracted"); + await mkdir(extractDir); + await tar.extract({ file: archivePath, cwd: extractDir }); + + const rootEntries = (await readdir(extractDir)).sort(); + expect(rootEntries).toEqual(["build.json", "dist"].sort()); + }); + + it("throws when the bundle dir is empty", async () => { + await expect(createBundleArchive(bundleDir, join(outDir, "bundle.tar.gz"))).rejects.toThrow( + /No files found/ + ); + }); +}); From b9ba3584f30c1823843baf15197af29cce2ac225 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 11:42:31 +0200 Subject: [PATCH 09/14] fix(cli): review feedback for --from-bundle error handling - wrap the bundle JSON parses in real try/catch so a corrupt build.json or trigger-build-args.json surfaces the intended error message instead of a raw SyntaxError (the eager JSON.parse threw before tryCatch could see it) - upsert the preview branch on fresh-init from-bundle deploys, matching the main deploy path (attach mode already has the branch env) --- packages/cli-v3/src/commands/deploy.ts | 29 ++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index c3cc92eece1..1433f86b319 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1708,7 +1708,14 @@ async function handleFromBundleDeploy({ ); } - const manifestResult = BuildManifest.safeParse(JSON.parse(manifestRaw)); + let manifestJson: unknown; + try { + manifestJson = JSON.parse(manifestRaw); + } catch { + throw new Error(`Invalid build.json in the bundle directory: not valid JSON`); + } + + const manifestResult = BuildManifest.safeParse(manifestJson); if (!manifestResult.success) { throw new Error(`Invalid build.json in the bundle directory: ${manifestResult.error.message}`); @@ -1724,11 +1731,13 @@ async function handleFromBundleDeploy({ ); if (!buildArgsError) { - const [parseError, parsed] = await tryCatch(Promise.resolve(JSON.parse(buildArgsRaw))); - if (parseError) { + let parsed: { env?: Record }; + try { + parsed = JSON.parse(buildArgsRaw); + } catch { throw new Error(`Invalid ${BUNDLE_BUILD_ARGS_FILE} in the bundle directory`); } - buildEnvVars = parsed.env ?? {}; + buildEnvVars = (typeof parsed === "object" && parsed !== null ? parsed.env : undefined) ?? {}; } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. buildEnvVars = bundleManifest.build.env; @@ -1744,6 +1753,18 @@ async function handleFromBundleDeploy({ ); } + // In attach mode the branch env already exists (it was created by whatever + // initialized the deployment); a fresh-init preview deploy needs the upsert. + if (options.env === "preview" && branch && !existingDeploymentId) { + await upsertBranch({ + accessToken: auth.accessToken, + apiUrl: auth.apiUrl, + projectRef, + branch, + gitMeta: undefined, + }); + } + const projectClient = await getProjectClient({ accessToken: auth.accessToken, apiUrl: auth.apiUrl, From e5f316a4aa3bdaf720eb1183aec1594a28e3f622 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 12:38:46 +0200 Subject: [PATCH 10/14] fix(webapp): treat preview environments as cloud installs isCloud() only matched the hardcoded cloud origins, so PR preview environments never initialized the billing client and anything gated on it silently no-op'd. Most visibly: remote builds were never enqueued and deployments sat queued forever. Preview origins now count as cloud. --- apps/webapp/app/services/platform.v3.server.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index a49de327cc9..78466665a8f 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1137,6 +1137,13 @@ export function isCloud(): boolean { return true; } + // PR preview environments are cloud-style installs running against the + // cloud's staging services. Without this, anything gated on the billing + // client silently no-ops there (e.g. remote builds never get enqueued). + if (env.LOGIN_ORIGIN.endsWith(".triggerlabs.dev")) { + return true; + } + if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") { return true; } From 5cf331f5ae3ab8d5a08936b36b71bea64d8e0924 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 13:13:47 +0200 Subject: [PATCH 11/14] fix(cli): stop the bundle archiver dropping the indexer entry points The bundler emits the controller entry points at paths mirroring the CLI's install location, which contains a node_modules segment when the CLI runs via npx. The archiver's blanket node_modules exclusion silently stripped them from the uploaded bundle, so the image build failed at the indexer stage with MODULE_NOT_FOUND. Only .DS_Store is excluded now. --- .../cli-v3/src/deploy/bundleArchive.test.ts | 35 ++++++++++++++++--- packages/cli-v3/src/deploy/bundleArchive.ts | 6 ++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/cli-v3/src/deploy/bundleArchive.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts index dc6a0af36d7..ca63c050151 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.test.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -60,11 +60,23 @@ describe("createBundleArchive", () => { expect(skill).toBe("# skill"); }); - it("excludes node_modules and .DS_Store but nothing else", async () => { + it("excludes only .DS_Store — node_modules paths must survive", async () => { await writeFile(join(bundleDir, "build.json"), "{}"); await writeFile(join(bundleDir, ".DS_Store"), "junk"); - await mkdir(join(bundleDir, "node_modules", "leftover"), { recursive: true }); - await writeFile(join(bundleDir, "node_modules", "leftover", "index.js"), "x"); + // The bundler emits controller entry points at paths mirroring the CLI's + // install location — under npx that contains a node_modules segment. Those + // files are load-bearing (the Containerfile's indexer stage runs them). + const controllerDir = join( + bundleDir, + ".npm", + "_npx", + "abc123", + "node_modules", + "trigger.dev", + "dist" + ); + await mkdir(controllerDir, { recursive: true }); + await writeFile(join(controllerDir, "managed-index-controller.mjs"), "x"); // dist-like names must NOT be excluded — the bundle IS build output await mkdir(join(bundleDir, "dist"), { recursive: true }); await writeFile(join(bundleDir, "dist", "chunk.mjs"), "x"); @@ -77,7 +89,22 @@ describe("createBundleArchive", () => { await tar.extract({ file: archivePath, cwd: extractDir }); const rootEntries = (await readdir(extractDir)).sort(); - expect(rootEntries).toEqual(["build.json", "dist"].sort()); + expect(rootEntries).toEqual(["build.json", "dist", ".npm"].sort()); + + const controller = await readFile( + join( + extractDir, + ".npm", + "_npx", + "abc123", + "node_modules", + "trigger.dev", + "dist", + "managed-index-controller.mjs" + ), + "utf-8" + ); + expect(controller).toBe("x"); }); it("throws when the bundle dir is empty", async () => { diff --git a/packages/cli-v3/src/deploy/bundleArchive.ts b/packages/cli-v3/src/deploy/bundleArchive.ts index ffb00c2ae90..d71998dc827 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.ts @@ -5,8 +5,10 @@ import { logger } from "../utilities/logger.js"; // The bundle dir is generated build output (bundled JS, synthesized package.json, // build.json, Containerfile, .trigger/skills). Unlike the source-context archiver, // it must NOT apply the usual build-output ignores (dist, build, .trigger) — those -// would strip the bundle itself. Only genuinely unwanted entries are excluded. -const BUNDLE_IGNORES = ["**/node_modules", "**/.DS_Store"]; +// would strip the bundle itself. node_modules must NOT be excluded either: the +// bundler emits the controller entry points at paths mirroring the CLI's install +// location, which contains a node_modules segment when the CLI runs via npx. +const BUNDLE_IGNORES = ["**/.DS_Store"]; /** * Archives a pre-built bundle directory (the buildWorker destination) so its From 84b6702e6bed2f279c820ea61fca1f631178061c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:09:40 +0200 Subject: [PATCH 12/14] feat(deploy): store local-bundle build env vars encrypted on the deployment Replaces the trigger-build-args.json file and generated .dockerignore: the bundle artifact is now secret-free. Build-arg values are sent with the init request, stored aes-256-gcm encrypted in a new WorkerDeployment.buildEnvVars column, and cleared on every terminal status transition. - new dedicated GET /api/v1/deployments/:id/build-env-vars endpoint, used by the from-bundle build in attach mode; returns an empty record for terminal deployments and never 500s on a bad envelope - size limits enforced server-side and pre-checked client-side (128 KiB serialized, 200 keys) - version-skew guard: the CLI hard-errors when it sent vars and the server did not ack storing them --- ...eployments.$deploymentId.build-env-vars.ts | 112 ++++++++++++++++ apps/webapp/app/routes/api.v1.deployments.ts | 7 +- ...eateDeploymentBackgroundWorkerV4.server.ts | 11 +- .../app/v3/services/deployment.server.ts | 4 +- .../app/v3/services/failDeployment.server.ts | 4 +- .../v3/services/finalizeDeployment.server.ts | 3 + .../services/initializeDeployment.server.ts | 39 ++++++ .../v3/services/timeoutDeployment.server.ts | 3 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 4 + packages/cli-v3/src/apiClient.ts | 15 +++ packages/cli-v3/src/commands/deploy.ts | 126 +++++++++++------- .../cli-v3/src/deploy/bundleArchive.test.ts | 5 +- packages/core/src/v3/schemas/api.ts | 28 +++- 14 files changed, 305 insertions(+), 58 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts create mode 100644 internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts new file mode 100644 index 00000000000..de8a621023e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -0,0 +1,112 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type GetDeploymentBuildEnvVarsResponseBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { decryptSecret, EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server"; +import { FINAL_DEPLOYMENT_STATUSES } from "~/v3/services/failDeployment.server"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +// Returns the decrypted build-time env vars stored on a fromBundle deployment. +// Deliberately separate from the main GET deployment endpoint: this is secret +// material, and a dedicated route keeps access explicit and auditable. The vars +// are cleared when the deployment reaches a terminal status, so this only ever +// serves the active build window. +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + try { + // Next authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const authenticatedEnv = authenticationResult.environment; + + const { deploymentId } = parsedParams.data; + + const deployment = await prisma.workerDeployment.findFirst({ + where: { + friendlyId: deploymentId, + environmentId: authenticatedEnv.id, + }, + select: { + id: true, + status: true, + buildEnvVars: true, + }, + }); + + if (!deployment) { + return json({ error: "Deployment not found" }, { status: 404 }); + } + + logger.info("Build env vars read", { + deploymentId, + environmentId: authenticatedEnv.id, + projectId: authenticatedEnv.projectId, + status: deployment.status, + hasVars: deployment.buildEnvVars !== null, + }); + + // Terminal deployments have their vars cleared; even if a clear is still in + // flight, never serve secrets for a build that is no longer active. + if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) { + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + if (!deployment.buildEnvVars) { + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars); + + if (!envelope.success) { + logger.error("Stored build env vars are not a valid encrypted envelope", { + deploymentId, + environmentId: authenticatedEnv.id, + }); + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + let variables: Record; + + try { + const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data); + variables = z.record(z.string()).parse(JSON.parse(decrypted)); + } catch (error) { + logger.error("Failed to decrypt stored build env vars", { + deploymentId, + environmentId: authenticatedEnv.id, + error, + }); + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to load deployment build env vars", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 39988e1d13d..ffa6d124e13 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -37,7 +37,10 @@ export async function action({ request, params }: ActionFunctionArgs) { const service = new InitializeDeploymentService(); try { - const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data); + const { deployment, imageRef, eventStream, buildEnvVarsStored } = await service.call( + authenticatedEnv, + body.data + ); const responseBody: InitializeDeploymentResponseBody = { id: deployment.friendlyId, @@ -49,6 +52,8 @@ export async function action({ request, params }: ActionFunctionArgs) { imageTag: imageRef, imagePlatform: deployment.imagePlatform, eventStream, + // Only ack when we actually stored vars; older CLIs ignore this field. + ...(buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), }; return json(responseBody, { status: 200 }); diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 66a17bb9f65..07e4e4f5d7f 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,9 +1,10 @@ import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; import { logger, tryCatch } from "@trigger.dev/core/v3"; -import type { - BackgroundWorker, - PrismaClientOrTransaction, - WorkerDeployment, +import { + Prisma, + type BackgroundWorker, + type PrismaClientOrTransaction, + type WorkerDeployment, } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { type TaskMetadataCache } from "~/services/taskMetadataCache.server"; @@ -288,6 +289,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { name: error.name, message: error.message, }, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 006894a695c..619f79ac318 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -1,7 +1,7 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService } from "./baseService.server"; import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; -import { type WorkerDeployment, type Project } from "@trigger.dev/database"; +import { Prisma, type WorkerDeployment, type Project } from "@trigger.dev/database"; import { BuildServerMetadata, logger, @@ -227,6 +227,8 @@ export class DeploymentService extends BaseService { status: "CANCELED", canceledAt: new Date(), canceledReason: data?.canceledReason, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }), (error) => ({ diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index 87b7618d76d..7b2221c5ccb 100644 --- a/apps/webapp/app/v3/services/failDeployment.server.ts +++ b/apps/webapp/app/v3/services/failDeployment.server.ts @@ -1,7 +1,7 @@ import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; import { BaseService } from "./baseService.server"; import { logger } from "~/services/logger.server"; -import { type WorkerDeploymentStatus } from "@trigger.dev/database"; +import { Prisma, type WorkerDeploymentStatus } from "@trigger.dev/database"; import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { DeploymentService } from "./deployment.server"; @@ -49,6 +49,8 @@ export class FailDeploymentService extends BaseService { status: "FAILED", failedAt: new Date(), errorData: params.error, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/finalizeDeployment.server.ts b/apps/webapp/app/v3/services/finalizeDeployment.server.ts index 98ef7a73bd5..842c977a661 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -1,4 +1,5 @@ import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; +import { Prisma } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; @@ -75,6 +76,8 @@ export class FinalizeDeploymentService extends BaseService { deployedAt: new Date(), // Only add the digest, if any imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index eb9db2ac20a..0034f9f3b58 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -6,6 +6,7 @@ import { import { customAlphabet } from "nanoid"; import { env } from "~/env.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { encryptSecret } from "~/services/secrets/secretStore.server"; import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; @@ -20,6 +21,11 @@ import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); +// Limits for fromBundle build env vars — they expand into --build-arg values, so +// keep them well under exec argv limits while staying generous for env vars. +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; + export class InitializeDeploymentService extends BaseService { public async call( environment: AuthenticatedEnvironment, @@ -56,6 +62,7 @@ export class InitializeDeploymentService extends BaseService { return { deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", + buildEnvVarsStored: false, }; } @@ -172,6 +179,36 @@ export class InitializeDeploymentService extends BaseService { } : undefined; + // Encrypt fromBundle build env vars for storage on the deployment row. Only + // meaningful for pre-bundled deploys; cleared on every terminal transition. + let encryptedBuildEnvVars: Awaited> | undefined; + + if ( + payload.isNativeBuild && + payload.fromBundle && + payload.buildEnvVars && + Object.keys(payload.buildEnvVars).length > 0 + ) { + const buildEnvVars = payload.buildEnvVars; + + const keyCount = Object.keys(buildEnvVars).length; + if (keyCount > BUILD_ENV_VARS_MAX_KEYS) { + throw new ServiceValidationError( + `Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).` + ); + } + + const serialized = JSON.stringify(buildEnvVars); + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) { + throw new ServiceValidationError( + `Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.` + ); + } + + encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized); + } + const buildServerMetadata: BuildServerMetadata | undefined = payload.isNativeBuild || payload.buildId ? { @@ -248,6 +285,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -312,6 +350,7 @@ export class InitializeDeploymentService extends BaseService { deployment, imageRef: deployment.imageReference ?? "", eventStream, + buildEnvVarsStored: encryptedBuildEnvVars !== undefined, }; }); } diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index fa3de698e36..573d1458b99 100644 --- a/apps/webapp/app/v3/services/timeoutDeployment.server.ts +++ b/apps/webapp/app/v3/services/timeoutDeployment.server.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@trigger.dev/database"; import { logger } from "~/services/logger.server"; import { BaseService } from "./baseService.server"; import { commonWorker } from "../commonWorker.server"; @@ -45,6 +46,8 @@ export class TimeoutDeploymentService extends BaseService { status: "TIMED_OUT", failedAt: new Date(), errorData: { message: errorMessage, name: "TimeoutError" }, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql b/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql new file mode 100644 index 00000000000..49e62e6e20d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN IF NOT EXISTS "buildEnvVars" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2800dd5dd2c..2ed828ce977 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2133,6 +2133,10 @@ model WorkerDeployment { externalBuildData Json? buildServerMetadata Json? + /// Encrypted build-time env vars for pre-bundled (fromBundle) deploys — an + /// EncryptedSecretValue envelope of a JSON record. Cleared when the deployment + /// reaches a terminal status; only ever exists for the active build window. + buildEnvVars Json? status WorkerDeploymentStatus @default(PENDING) type WorkerDeploymentType @default(V1) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index d01062440e9..c9ac0c883fa 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -23,6 +23,7 @@ import { DevDisconnectResponseBody, EnvironmentVariableResponseBody, FailDeploymentResponseBody, + GetDeploymentBuildEnvVarsResponseBody, GetDeploymentResponseBody, GetEnvironmentVariablesResponseBody, GetLatestDeploymentResponseBody, @@ -592,6 +593,20 @@ export class CliApiClient { ); } + async getDeploymentBuildEnvVars(deploymentId: string) { + if (!this.accessToken) { + throw new Error("getDeploymentBuildEnvVars: No access token"); + } + + return wrapZodFetch( + GetDeploymentBuildEnvVarsResponseBody, + `${this.apiURL}/api/v1/deployments/${deploymentId}/build-env-vars`, + { + headers: this.getHeaders(), + } + ); + } + async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) { if (!this.accessToken) { return { success: true as const, data: { notification: null } }; diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 1433f86b319..e3e6027e304 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -21,7 +21,7 @@ import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; import { createBundleArchive } from "../deploy/bundleArchive.js"; import { S2 } from "@s2-dev/streamstore"; -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, unlink } from "node:fs/promises"; import { CommonCommandOptions, commonOptions, @@ -49,7 +49,7 @@ import { prettyWarning, } from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; -import { isDirectory, writeJSONFile } from "../utilities/fileSystem.js"; +import { isDirectory } from "../utilities/fileSystem.js"; import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; import { createGitMeta, isGitHubActions } from "../utilities/gitMeta.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; @@ -97,12 +97,12 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; -// Carries the build-arg VALUES for the `ARG` lines in the generated Containerfile. -// They only exist in the in-memory build manifest (build.json is deliberately scrubbed -// because it gets COPY'd into the image), so --local-bundle writes them to this file -// and --from-bundle reads them back. A .dockerignore entry keeps the file out of the -// image COPY context so the values never land in image layers. -const BUNDLE_BUILD_ARGS_FILE = "trigger-build-args.json"; +// Limits for the build-arg VALUES sent with --local-bundle deploys (they only exist in +// the in-memory build manifest — build.json is deliberately scrubbed because it gets +// COPY'd into the image). The server enforces the same limits authoritatively; this +// pre-check just fails fast with a friendly error before uploading anything. +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; export function configureDeployCommand(program: Command) { return ( @@ -1134,6 +1134,9 @@ async function handleNativeBuildServerDeploy({ // server then runs just the container build from it. let bundleManifest: BuildManifest | undefined; let bundleOutputPath: string | undefined; + // Build-arg values for --local-bundle, sent with the init request and stored + // encrypted on the deployment (they're scrubbed from build.json). + let bundleBuildEnvVars: Record | undefined; if (options.localBundle) { // The container build runs on the build server with its own fixed settings — @@ -1196,23 +1199,25 @@ async function handleNativeBuildServerDeploy({ bundleManifest = buildManifest; bundleOutputPath = destination.path; - // Persist the build-arg values (scrubbed from build.json) for the build server's - // --from-bundle step, and keep them out of the image via .dockerignore. - await writeJSONFile(join(destination.path, BUNDLE_BUILD_ARGS_FILE), { - env: buildManifest.build.env ?? {}, - }); + // The build-arg values (scrubbed from build.json) travel via the deployment + // record (sent with the init request, stored encrypted server-side) — never + // as a file in the bundle. Pre-check the limits the server enforces. + bundleBuildEnvVars = buildManifest.build.env ?? {}; - // Append to a .dockerignore a build extension may have produced, never clobber it. - // Our exclusions always go LAST so a pre-existing negation (!file) can't re-include - // the build-args file into the image context. - const dockerignorePath = join(destination.path, ".dockerignore"); - const [, existingDockerignore] = await tryCatch(readFile(dockerignorePath, "utf-8")); - await writeFile( - dockerignorePath, - `${ - existingDockerignore ? existingDockerignore.trimEnd() + "\n" : "" - }${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n` - ); + const buildEnvVarCount = Object.keys(bundleBuildEnvVars).length; + const buildEnvVarBytes = Buffer.byteLength(JSON.stringify(bundleBuildEnvVars), "utf8"); + + if (buildEnvVarCount > BUILD_ENV_VARS_MAX_KEYS) { + throw new Error( + `Your build uses too many build environment variables: ${buildEnvVarCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).` + ); + } + + if (buildEnvVarBytes > BUILD_ENV_VARS_MAX_BYTES) { + throw new Error( + `Your build environment variables are too large: ${buildEnvVarBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.` + ); + } if (options.dryRun) { logger.info(`Dry run complete. View the built bundle at ${destination.path}`); @@ -1349,6 +1354,10 @@ async function handleNativeBuildServerDeploy({ configFilePath, triggeredVia: getTriggeredVia(), fromBundle: options.localBundle ? true : undefined, + buildEnvVars: + options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0 + ? bundleBuildEnvVars + : undefined, }); if (!initializeDeploymentResult.success) { @@ -1359,6 +1368,26 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; + // Version-skew guard: an older server silently strips unknown fields, so if we sent + // build env vars and the server didn't ack storing them, the remote build would run + // without them and fail in a confusing way. Fail fast instead. + if ( + options.localBundle && + bundleBuildEnvVars && + Object.keys(bundleBuildEnvVars).length > 0 && + !deployment.buildEnvVarsStored + ) { + $deploymentSpinner.stop("Failed to initialize deployment"); + log.error( + chalk.bold( + chalkError( + "This server does not support --local-bundle deploys with build environment variables yet. Deploy without --local-bundle instead." + ) + ) + ); + throw new OutroCommandError(`Deployment failed`); + } + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ options.env === "prod" ? "prod" : "stg" @@ -1675,8 +1704,8 @@ export function verifyDirectory(dir: string, projectPath: string) { // the bundling step, as produced by --local-bundle / a dry-run build). Used primarily // by the build server to run ONLY the container build for pre-bundled artifacts, but // also works standalone for local testing. Skips config loading entirely — the bundle -// has no trigger.config.ts source; everything needed comes from the bundle's build.json, -// the build-args file, and the deployment record. +// has no trigger.config.ts source; everything needed comes from the bundle's build.json +// and the deployment record (including the build-arg values, stored encrypted there). async function handleFromBundleDeploy({ bundleDir, options, @@ -1723,26 +1752,6 @@ async function handleFromBundleDeploy({ const bundleManifest = manifestResult.data; - // Recover the build-arg values scrubbed from build.json (written by --local-bundle). - // Optional: bundles without build-time env vars may not carry the file. - let buildEnvVars: Record | undefined; - const [buildArgsError, buildArgsRaw] = await tryCatch( - readFile(join(bundlePath, BUNDLE_BUILD_ARGS_FILE), "utf-8") - ); - - if (!buildArgsError) { - let parsed: { env?: Record }; - try { - parsed = JSON.parse(buildArgsRaw); - } catch { - throw new Error(`Invalid ${BUNDLE_BUILD_ARGS_FILE} in the bundle directory`); - } - buildEnvVars = (typeof parsed === "object" && parsed !== null ? parsed.env : undefined) ?? {}; - } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { - // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. - buildEnvVars = bundleManifest.build.env; - } - const projectRef = projectRefOverride ?? bundleManifest.config.project; const branch = options.env === "preview" ? getBranch({ specified: options.branch }) : undefined; @@ -1778,11 +1787,34 @@ async function handleFromBundleDeploy({ throw new Error("Failed to get project client"); } + // Recover the build-arg values scrubbed from build.json. In attach mode they were + // stored encrypted on the deployment by --local-bundle's init request; fetch them + // through the dedicated endpoint. An empty record is normal for builds that use no + // build-time env vars. + let buildEnvVars: Record | undefined; + + if (existingDeploymentId) { + const buildEnvVarsResult = + await projectClient.client.getDeploymentBuildEnvVars(existingDeploymentId); + + if (!buildEnvVarsResult.success) { + throw new Error( + `Failed to fetch the build environment variables for deployment ${existingDeploymentId}: ${buildEnvVarsResult.error}` + ); + } + + buildEnvVars = buildEnvVarsResult.data.variables; + } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { + // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. + buildEnvVars = bundleManifest.build.env; + } + if (!existingDeploymentId) { // The supported flow is attach mode (the build server sets // TRIGGER_EXISTING_DEPLOYMENT_ID). Fresh-init from a bundle is equivalent to a // plain local build and mainly useful for local testing — warn so nobody relies - // on it against cloud by accident. + // on it against cloud by accident. There are no stored build env vars on this + // path; the build proceeds without them. logger.warn( "No existing deployment to attach to — initializing a fresh local-build deployment from the bundle. This path is intended for testing." ); diff --git a/packages/cli-v3/src/deploy/bundleArchive.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts index ca63c050151..c35a5875dc7 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.test.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -25,8 +25,8 @@ describe("createBundleArchive", () => { await writeFile(join(bundleDir, "Containerfile"), "FROM scratch"); await writeFile(join(bundleDir, "package.json"), "{}"); await writeFile(join(bundleDir, "index.mjs"), "export {}"); - await writeFile(join(bundleDir, ".dockerignore"), "trigger-build-args.json\n"); - await writeFile(join(bundleDir, "trigger-build-args.json"), JSON.stringify({ env: {} })); + // A build extension may produce a .dockerignore — it must survive archiving + await writeFile(join(bundleDir, ".dockerignore"), "*.log\n"); await mkdir(join(bundleDir, ".trigger", "skills", "my-skill"), { recursive: true }); await writeFile(join(bundleDir, ".trigger", "skills", "my-skill", "SKILL.md"), "# skill"); @@ -48,7 +48,6 @@ describe("createBundleArchive", () => { "build.json", "index.mjs", "package.json", - "trigger-build-args.json", ].sort() ); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index efd260f062d..0872c269be3 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -680,6 +680,9 @@ export const InitializeDeploymentResponseBody = z.object({ }), }) .optional(), + // Ack that the server accepted and stored buildEnvVars from the request. The CLI + // treats its absence (older server) as a hard error when it sent non-empty vars. + buildEnvVarsStored: z.boolean().optional(), }); export type InitializeDeploymentResponseBody = z.infer; @@ -706,6 +709,7 @@ type NativeBuildOutput = BaseOutput & { configFilePath?: string; skipEnqueue?: boolean; fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -715,6 +719,7 @@ type NonNativeBuildOutput = BaseOutput & { configFilePath?: never; skipEnqueue?: never; fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -726,6 +731,9 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase. // The uploaded artifact is a pre-built bundle (local install + bundle already done); // the build server should skip install/bundle and only run the container build. fromBundle: z.boolean().optional(), + // Build-time env var values for fromBundle deploys. Stored encrypted on the + // deployment and cleared once the deployment reaches a terminal status. + buildEnvVars: z.record(z.string()).optional(), }); export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform( @@ -733,7 +741,15 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu if (data.isNativeBuild) { return { ...data, isNativeBuild: true as const }; } - const { skipPromotion, artifactKey, configFilePath, skipEnqueue, fromBundle, ...rest } = data; + const { + skipPromotion, + artifactKey, + configFilePath, + skipEnqueue, + fromBundle, + buildEnvVars, + ...rest + } = data; return { ...rest, isNativeBuild: false as const }; } ); @@ -838,6 +854,16 @@ export const GetDeploymentResponseBody = z.object({ export type GetDeploymentResponseBody = z.infer; +// Response of the dedicated build-env-vars endpoint (secret material — deliberately +// kept off GetDeploymentResponseBody). Empty record when none were stored. +export const GetDeploymentBuildEnvVarsResponseBody = z.object({ + variables: z.record(z.string()), +}); + +export type GetDeploymentBuildEnvVarsResponseBody = z.infer< + typeof GetDeploymentBuildEnvVarsResponseBody +>; + export const GetLatestDeploymentResponseBody = GetDeploymentResponseBody.omit({ worker: true, }); From e19a9712132f16366f046ffca5ff6b07cc5909e4 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:18:31 +0200 Subject: [PATCH 13/14] fix(deploy): drop undefined build env var values before sending Extensions can set undefined values at runtime (e.g. env?.MISSING_VAR) despite the manifest type. JSON serialization strips them, so the client side emptiness check disagreed with what the server received and the version-skew guard misfired. --- packages/cli-v3/src/commands/deploy.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index e3e6027e304..b00f4975b15 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1201,8 +1201,14 @@ async function handleNativeBuildServerDeploy({ // The build-arg values (scrubbed from build.json) travel via the deployment // record (sent with the init request, stored encrypted server-side) — never - // as a file in the bundle. Pre-check the limits the server enforces. - bundleBuildEnvVars = buildManifest.build.env ?? {}; + // as a file in the bundle. Despite the manifest type, extensions can set + // undefined values at runtime (e.g. env?.MISSING_VAR) — drop those, they'd + // be stripped by JSON serialization anyway. Pre-check the server's limits. + bundleBuildEnvVars = Object.fromEntries( + Object.entries(buildManifest.build.env ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); const buildEnvVarCount = Object.keys(bundleBuildEnvVars).length; const buildEnvVarBytes = Buffer.byteLength(JSON.stringify(bundleBuildEnvVars), "utf8"); From 9ac659bfcd2d35f069f741efcc01161e5dee0c6a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:25:03 +0200 Subject: [PATCH 14/14] fix(deploy): fail loud when stored build env vars cannot be read Review feedback: a corrupt or undecryptable envelope previously returned an empty record, indistinguishable from no vars at all, letting the remote build run without its build-time secrets. The endpoint now returns an error so the build aborts with an actionable message. An empty record remains the response only for deployments that genuinely have none or are terminal. Also cancel the deployment best-effort when the version-skew guard aborts, instead of leaving it pending until the queue timeout reaps it. --- ...eployments.$deploymentId.build-env-vars.ts | 20 +++++++++++++------ packages/cli-v3/src/apiClient.ts | 13 ++++++++++++ packages/cli-v3/src/commands/deploy.ts | 12 +++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts index de8a621023e..561f8daf65b 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -75,6 +75,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }); } + // Vars exist but can't be read: fail LOUD. Returning an empty record here would + // be indistinguishable from "there were none" and let the build run without its + // build-time secrets (confusing failure at best, silently-wrong image at worst). + // Concrete trigger: ENCRYPTION_KEY rotation during the build window. const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars); if (!envelope.success) { @@ -82,9 +86,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { deploymentId, environmentId: authenticatedEnv.id, }); - return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { - status: 200, - }); + return json( + { error: "The stored build environment variables could not be read. Retry the deploy." }, + { status: 500 } + ); } let variables: Record; @@ -98,9 +103,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { environmentId: authenticatedEnv.id, error, }); - return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { - status: 200, - }); + return json( + { + error: "The stored build environment variables could not be decrypted. Retry the deploy.", + }, + { status: 500 } + ); } return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 }); diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index c9ac0c883fa..e139438a4dd 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -593,6 +593,19 @@ export class CliApiClient { ); } + // Best-effort cancel (204 on success, no body) — callers may ignore failures. + async cancelDeployment(deploymentId: string, reason?: string) { + if (!this.accessToken) { + throw new Error("cancelDeployment: No access token"); + } + + return fetch(`${this.apiURL}/api/v1/deployments/${deploymentId}/cancel`, { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify({ reason }), + }); + } + async getDeploymentBuildEnvVars(deploymentId: string) { if (!this.accessToken) { throw new Error("getDeploymentBuildEnvVars: No access token"); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index b00f4975b15..ccbc1daef5b 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1383,6 +1383,18 @@ async function handleNativeBuildServerDeploy({ Object.keys(bundleBuildEnvVars).length > 0 && !deployment.buildEnvVarsStored ) { + // Courtesy cancel so the deployment doesn't linger as PENDING until the + // queue timeout reaps it. Best-effort: the hard error below is what matters. + const [cancelError] = await tryCatch( + apiClient.cancelDeployment(deployment.id, "Build environment variables were not stored") + ); + if (cancelError) { + logger.debug("Failed to cancel deployment after missing build env vars ack", { + deploymentId: deployment.id, + error: cancelError, + }); + } + $deploymentSpinner.stop("Failed to initialize deployment"); log.error( chalk.bold(