From 81c5d4ed743a364a99d5d61d9d527fbb9b0e444e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:22:13 +0200 Subject: [PATCH 01/18] 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 08dc4d9ca0..a5b4b58bb0 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -654,6 +654,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; @@ -720,7 +721,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(), }); @@ -784,6 +785,7 @@ type NativeBuildOutput = BaseOutput & { artifactKey?: string; configFilePath?: string; skipEnqueue?: boolean; + fromBundle?: boolean; }; type NonNativeBuildOutput = BaseOutput & { @@ -792,6 +794,7 @@ type NonNativeBuildOutput = BaseOutput & { artifactKey?: never; configFilePath?: never; skipEnqueue?: never; + fromBundle?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -800,6 +803,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(), }).superRefine((data, ctx) => { if (data.force && !data.externalId) { ctx.addIssue({ @@ -815,7 +821,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 66101016c15963dc33293299f283554960344535 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:28:29 +0200 Subject: [PATCH 02/18] 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 | 372 ++++++++++++++++++-- packages/cli-v3/src/deploy/bundleArchive.ts | 45 +++ 2 files changed, 390 insertions(+), 27 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 7afa06982a..46999b9792 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -12,7 +12,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"; @@ -24,8 +24,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, @@ -54,7 +55,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"; @@ -90,6 +91,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"), @@ -102,6 +105,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( @@ -248,6 +258,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", @@ -335,6 +362,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; + } + let resolvedConfig = await loadConfig({ cwd: projectPath, overrides: { project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF }, @@ -421,6 +463,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { options, userId: userIdForDeploy(authorization), gitMeta, + branch, }); return; } @@ -535,8 +578,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) { @@ -604,11 +645,56 @@ 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, rawTestLink } = buildDeploymentLinks({ - dashboardUrl: authorization.dashboardUrl, - projectRef: resolvedConfig.project, + dashboardUrl, + projectRef, env: options.env, shortCode: deployment.shortCode, }); @@ -648,15 +734,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, @@ -691,7 +777,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"); @@ -700,7 +786,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!warnings.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: warnings.summary }, buildResult.logs, @@ -714,7 +800,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!buildResult.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: buildResult.error }, buildResult.logs, @@ -725,11 +811,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, @@ -747,7 +833,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { : undefined; await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", @@ -772,7 +858,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } - const finalizeResponse = await projectClient.client.finalizeDeployment( + const finalizeResponse = await apiClient.finalizeDeployment( deployment.id, { imageDigest: buildResult.digest, @@ -797,7 +883,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!finalizeResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "FinalizeError", message: finalizeResponse.error }, buildResult.logs, @@ -1199,6 +1285,7 @@ async function handleNativeBuildServerDeploy({ dashboardUrl, userId, gitMeta, + branch, }: { apiClient: CliApiClient; config: Awaited>; @@ -1206,23 +1293,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, }); @@ -1288,11 +1437,11 @@ 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, @@ -1300,6 +1449,7 @@ async function handleNativeBuildServerDeploy({ triggeredVia: getTriggeredVia(), externalId: options.externalId, force: options.force, + fromBundle: options.localBundle ? true : undefined, }); if (!initializeDeploymentResult.success) { @@ -1310,6 +1460,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" @@ -1635,3 +1829,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 0000000000..ffb00c2ae9 --- /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 645a4a806c0f5ed2b34660a3262bcc10bbaf7237 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:30:20 +0200 Subject: [PATCH 03/18] 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 0000000000..b6f88cda6b --- /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 a706f9e04e..12c2a10a9e 100644 --- a/apps/webapp/app/routes/api.v1.artifacts.ts +++ b/apps/webapp/app/routes/api.v1.artifacts.ts @@ -64,6 +64,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 01b1c7d497..09fbc0d8e0 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1095,6 +1095,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 9e82af5123..7a06f1cac6 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 c67d777856..d4903ba535 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 c5b01c6084..7097351d19 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -279,6 +279,7 @@ export class InitializeDeploymentService extends BaseService { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, skipEnqueue: payload.skipEnqueue, + fromBundle: payload.fromBundle, } : {}), } @@ -373,6 +374,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 18c44a5a466a7024c8719043bbe060010077a672 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 18:50:25 +0200 Subject: [PATCH 04/18] 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 7a06f1cac6..fa5996a247 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 46999b9792..45eba69c19 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1351,10 +1351,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(); @@ -1441,7 +1455,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 d7fb9ba6d708f3b7a688dc26cb333857b0162adc Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 19:11:48 +0200 Subject: [PATCH 05/18] 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 45eba69c19..dbdb6fda78 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1310,7 +1310,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 }); @@ -1352,23 +1353,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(); @@ -1476,50 +1512,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" @@ -1932,6 +1924,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 978bbba26b0dfc8f93246ad2daf6aba368ca31ab Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 19:21:47 +0200 Subject: [PATCH 06/18] 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 56ddae17c0..d5e8d3a8d3 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -75,6 +75,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 45f2f6f5c96d9870eb801152d263e9c7e7676793 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 21:33:23 +0200 Subject: [PATCH 07/18] 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 dbdb6fda78..949165e284 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1307,6 +1307,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 c56457809019f2f6cfbeb4391aeeb25c5e000132 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 21 Jul 2026 22:45:38 +0200 Subject: [PATCH 08/18] 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 0000000000..dc6a0af36d --- /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 332549f6db2972bcecc5ff4af9b5d529caf6b1f8 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 11:42:31 +0200 Subject: [PATCH 09/18] 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 949165e284..51a7b683f7 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1895,7 +1895,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}`); @@ -1911,11 +1918,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; @@ -1931,6 +1940,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 5c373ad6437641d3f69d95bd809fd32820cbe616 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 12:38:46 +0200 Subject: [PATCH 10/18] 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 09fbc0d8e0..850b03c3d4 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1236,6 +1236,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 08ce247f062e6deb5fd4edf5e09dbe28b516ef70 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 13:13:47 +0200 Subject: [PATCH 11/18] 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 dc6a0af36d..ca63c05015 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 ffb00c2ae9..d71998dc82 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 1e226e1970299537f511fac38a71cee954867682 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:09:40 +0200 Subject: [PATCH 12/18] 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 | 2 + ...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 | 40 ++++++ .../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, 302 insertions(+), 57 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 0000000000..de8a621023 --- /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 5be291bae2..5ef38acd54 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -60,6 +60,8 @@ export async function action({ request, params }: ActionFunctionArgs) { .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], eventStream: result.eventStream, canceledDeployments: result.canceledDeployments, + // Only ack when we actually stored vars; older CLIs ignore this field. + ...(result.buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), } : { isPromoted: result.isPromoted }), }; diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 305ee45ce2..4cdd36d08d 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"; @@ -313,6 +314,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 d4903ba535..3bf900b107 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, boundedIn } from "@trigger.dev/database"; +import { Prisma, type WorkerDeployment, type Project, boundedIn } 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 87b7618d76..7b2221c5cc 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 0595cee1e2..62576ed675 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"; @@ -76,6 +77,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 7097351d19..37d256a75d 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"; @@ -29,6 +30,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; + type DeploymentEventStream = { s2: { basin: string; @@ -44,6 +50,7 @@ export type InitializeDeploymentResult = imageRef: string; eventStream?: DeploymentEventStream; canceledDeployments?: SupersededDeployment[]; + buildEnvVarsStored?: boolean; } | { outcome: "existing"; @@ -103,6 +110,7 @@ export class InitializeDeploymentService extends BaseService { outcome: "created", deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", + buildEnvVarsStored: false, }; } @@ -268,6 +276,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 ? { @@ -344,6 +382,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -411,6 +450,7 @@ export class InitializeDeploymentService extends BaseService { imageRef: deployment.imageReference ?? "", eventStream, canceledDeployments, + buildEnvVarsStored: encryptedBuildEnvVars !== undefined, }; }); } diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index fa3de698e3..573d1458b9 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 0000000000..49e62e6e20 --- /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 470f2c251c..81417db75e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2271,6 +2271,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 fba2e52e1e..8b9fd56eb1 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, @@ -689,6 +690,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 51a7b683f7..d2f03c2030 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -26,7 +26,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, @@ -55,7 +55,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"; @@ -105,12 +105,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 ( @@ -1305,6 +1305,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 — @@ -1367,23 +1370,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}`); @@ -1522,6 +1527,10 @@ async function handleNativeBuildServerDeploy({ externalId: options.externalId, force: options.force, fromBundle: options.localBundle ? true : undefined, + buildEnvVars: + options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0 + ? bundleBuildEnvVars + : undefined, }); if (!initializeDeploymentResult.success) { @@ -1532,6 +1541,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" @@ -1862,8 +1891,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, @@ -1910,26 +1939,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; @@ -1965,11 +1974,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 ca63c05015..c35a5875dc 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 a5b4b58bb0..7e256e41f9 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -758,6 +758,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; @@ -786,6 +789,7 @@ type NativeBuildOutput = BaseOutput & { configFilePath?: string; skipEnqueue?: boolean; fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -795,6 +799,7 @@ type NonNativeBuildOutput = BaseOutput & { configFilePath?: never; skipEnqueue?: never; fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -806,6 +811,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(), }).superRefine((data, ctx) => { if (data.force && !data.externalId) { ctx.addIssue({ @@ -821,7 +829,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 }; } ); @@ -927,6 +943,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 4a0eb683aba1c4bade78e85d8398623fe5e07078 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:18:31 +0200 Subject: [PATCH 13/18] 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 d2f03c2030..10b7b1b6e4 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1372,8 +1372,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 f3594dc47b210f061d4579e4962fe72686055887 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 22 Jul 2026 18:25:03 +0200 Subject: [PATCH 14/18] 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 de8a621023..561f8daf65 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 8b9fd56eb1..9afadb0ab4 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -690,6 +690,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 10b7b1b6e4..1763091396 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1556,6 +1556,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( From 8abbe0571a6680c3097f25f2f3a5039a4f6aac1c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 24 Jul 2026 15:49:47 +0200 Subject: [PATCH 15/18] fix(webapp): tolerate missing LOGIN_ORIGIN in isCloud Test suites mock the env module with partial objects and import this module transitively, so the preview-host check must not assume the variable is set even though the schema gives it a default. --- apps/webapp/app/services/platform.v3.server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 850b03c3d4..4e403fd34f 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1239,7 +1239,9 @@ export function isCloud(): boolean { // 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")) { + // Optional chaining: LOGIN_ORIGIN has a schema default, but test suites mock + // ~/env.server with partial objects and import this module transitively. + if (env.LOGIN_ORIGIN?.endsWith(".triggerlabs.dev")) { return true; } From bf9b894da9fe975e5fa216c70f58bcea86de6f05 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 21 Aug 2026 10:01:06 +0200 Subject: [PATCH 16/18] fix(deploy): adapt build env vars flow to reused deployments and scoped auth Two adaptations to changes that landed on main: - the version-skew ack guard now runs after the reused-deployment early return, since an externally reused deployment builds nothing and never stores build env vars - the build-env-vars endpoint uses the same scoped api key auth as the sibling deployment route, which migrated to authenticateApiKeyWithScope --- ...eployments.$deploymentId.build-env-vars.ts | 16 ++++-- packages/cli-v3/src/commands/deploy.ts | 57 ++++++++++--------- 2 files changed, 39 insertions(+), 34 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 561f8daf65..84e36256c1 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 @@ -3,7 +3,7 @@ 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 { authenticateApiKeyWithScope } 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"; @@ -25,15 +25,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } try { - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + // Same auth as the sibling GET deployment route: env-key principals with + // read scope on deployments, no JWT. + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const authenticatedEnv = authenticationResult.environment; + const authenticatedEnv = authResult.authentication.environment; const { deploymentId } = parsedParams.data; diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 1763091396..a01068d2c5 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1547,9 +1547,37 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; + const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ + options.env === "prod" ? "prod" : "stg" + }`; + + if (deployment.outcome === "existing") { + $deploymentSpinner.stop(`Version ${deployment.version} was already deployed`); + + setDeploymentGithubActionsOutput({ + version: deployment.version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: !deployment.isPromoted, + }); + + warnAboutSkippedBuild(options.externalId, deployment.isPromoted); + + outro( + `Version ${deployment.version} was already deployed for --external-id ${options.externalId} — nothing to build ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : rawDeploymentLink + }` + ); + + return; + } + // 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. + // without them and fail in a confusing way. Fail fast instead. Deliberately after + // the outcome=existing return: a reused deployment builds nothing, so no ack is due. if ( options.localBundle && bundleBuildEnvVars && @@ -1579,33 +1607,6 @@ async function handleNativeBuildServerDeploy({ 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" - }`; - - if (deployment.outcome === "existing") { - $deploymentSpinner.stop(`Version ${deployment.version} was already deployed`); - - setDeploymentGithubActionsOutput({ - version: deployment.version, - shortCode: deployment.shortCode, - rawDeploymentLink, - rawTestLink, - needsPromotion: !deployment.isPromoted, - }); - - warnAboutSkippedBuild(options.externalId, deployment.isPromoted); - - outro( - `Version ${deployment.version} was already deployed for --external-id ${options.externalId} — nothing to build ${ - isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : rawDeploymentLink - }` - ); - - return; - } - const exposedDeploymentLink = isLinksSupported ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) : chalk.bold(rawDeploymentLink); From 03f3cf04d2610ebc950421d901781d2b1b50a78e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 21 Aug 2026 10:37:54 +0200 Subject: [PATCH 17/18] fix(cli): honor --dry-run with --from-bundle and guard bundle artifact type Review feedback: - --from-bundle now exits after validating the bundle manifest when --dry-run is set, before any server calls, matching the other deploy paths - --local-bundle hard-errors when the created artifact key lacks the bundle-specific prefix, catching older servers that silently store the upload as a plain source context even when no build env vars are sent --- packages/cli-v3/src/commands/deploy.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index a01068d2c5..7bbf7e34a4 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1467,6 +1467,23 @@ async function handleNativeBuildServerDeploy({ logger.debug("Artifact created", { artifactKey }); + // Version-skew guard: an older server that does not know the deployment_bundle + // artifact type silently stores the upload as a plain source context, and the + // remote build would then try to install and bundle an already-bundled directory. + // The bundle-specific key prefix doubles as the ack that the server understood + // the type, independent of whether any build env vars are sent later. + if (options.localBundle && !artifactKey.startsWith("bundles/")) { + $deploymentSpinner.stop("Failed creating deployment artifact"); + log.error( + chalk.bold( + chalkError( + "This server does not support --local-bundle deploys yet. Deploy without --local-bundle instead." + ) + ) + ); + throw new OutroCommandError(`Deployment failed`); + } + $deploymentSpinner.message("Uploading deployment files"); const [readError, fileBuffer] = await tryCatch(readFile(archivePath)); @@ -1958,6 +1975,13 @@ async function handleFromBundleDeploy({ const bundleManifest = manifestResult.data; + // Match the other deploy paths' promise: --dry-run never touches the server. + // Exit after the manifest is validated, before any branch/deployment calls. + if (options.dryRun) { + logger.info(`Dry run complete. Validated bundle at ${bundlePath}`); + return; + } + const projectRef = projectRefOverride ?? bundleManifest.config.project; const branch = options.env === "preview" ? getBranch({ specified: options.branch }) : undefined; From c1d03d6d5dce9b1a30978054ca8bc9512a947c38 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Fri, 21 Aug 2026 11:37:38 +0200 Subject: [PATCH 18/18] chore: trim inline comments to essential constraints --- ...eployments.$deploymentId.build-env-vars.ts | 16 +--- apps/webapp/app/routes/api.v1.deployments.ts | 1 - .../webapp/app/services/platform.v3.server.ts | 7 +- .../app/v3/services/artifacts.server.ts | 4 +- ...eateDeploymentBackgroundWorkerV4.server.ts | 1 - .../app/v3/services/deployment.server.ts | 1 - .../app/v3/services/failDeployment.server.ts | 1 - .../v3/services/finalizeDeployment.server.ts | 1 - .../services/initializeDeployment.server.ts | 5 +- .../v3/services/timeoutDeployment.server.ts | 1 - apps/webapp/vite.config.ts | 3 +- .../database/prisma/schema.prisma | 5 +- packages/cli-v3/src/apiClient.ts | 2 +- packages/cli-v3/src/commands/deploy.ts | 74 ++++--------------- .../cli-v3/src/deploy/bundleArchive.test.ts | 10 +-- packages/cli-v3/src/deploy/bundleArchive.ts | 15 +--- packages/core/src/v3/schemas/api.ts | 12 +-- 17 files changed, 36 insertions(+), 123 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 84e36256c1..3edb3b8a81 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 @@ -12,11 +12,7 @@ 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. +// Secret material, deliberately separate from the main GET deployment endpoint. export async function loader({ request, params }: LoaderFunctionArgs) { const parsedParams = ParamsSchema.safeParse(params); @@ -25,8 +21,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } try { - // Same auth as the sibling GET deployment route: env-key principals with - // read scope on deployments, no JWT. const authResult = await authenticateApiKeyWithScope(request, { action: "read", resource: { type: "deployments" }, @@ -65,8 +59,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { 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. + // Never serve secrets for a build that is no longer active, even if a clear is still in flight if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) { return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200, @@ -79,10 +72,7 @@ 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. + // Present-but-unreadable must fail loud: an empty record would let the build run without its secrets const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars); if (!envelope.success) { diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 5ef38acd54..98bd151afa 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -60,7 +60,6 @@ export async function action({ request, params }: ActionFunctionArgs) { .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], eventStream: result.eventStream, canceledDeployments: result.canceledDeployments, - // Only ack when we actually stored vars; older CLIs ignore this field. ...(result.buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), } : { isPromoted: result.isPromoted }), diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 4e403fd34f..48fdd22708 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1236,11 +1236,8 @@ 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). - // Optional chaining: LOGIN_ORIGIN has a schema default, but test suites mock - // ~/env.server with partial objects and import this module transitively. + // Preview environments are cloud installs too; without this the billing client silently no-ops. + // Optional chaining because test suites mock the env module with partial objects. if (env.LOGIN_ORIGIN?.endsWith(".triggerlabs.dev")) { return true; } diff --git a/apps/webapp/app/v3/services/artifacts.server.ts b/apps/webapp/app/v3/services/artifacts.server.ts index fa5996a247..85a742629e 100644 --- a/apps/webapp/app/v3/services/artifacts.server.ts +++ b/apps/webapp/app/v3/services/artifacts.server.ts @@ -24,9 +24,7 @@ const objectStoreClient = const artifactKeyPrefixByType = { deployment_context: "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. + // The key prefix is the one bundle signal that survives schema skew deployment_bundle: "bundles", } as const; const artifactBytesSizeLimitByType = { diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 4cdd36d08d..d09707a0e8 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -314,7 +314,6 @@ 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 3bf900b107..7a891ae4f6 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -227,7 +227,6 @@ 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, }, }), diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index 7b2221c5cc..cb5c622b7b 100644 --- a/apps/webapp/app/v3/services/failDeployment.server.ts +++ b/apps/webapp/app/v3/services/failDeployment.server.ts @@ -49,7 +49,6 @@ 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 62576ed675..51f5b1e37c 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -77,7 +77,6 @@ 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 37d256a75d..eed46d7e6d 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -30,8 +30,7 @@ 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. +// Build env vars expand into --build-arg values, so stay well under exec argv limits const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; const BUILD_ENV_VARS_MAX_KEYS = 200; @@ -276,8 +275,6 @@ 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 ( diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index 573d1458b9..5e417a7863 100644 --- a/apps/webapp/app/v3/services/timeoutDeployment.server.ts +++ b/apps/webapp/app/v3/services/timeoutDeployment.server.ts @@ -46,7 +46,6 @@ 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/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index d5e8d3a8d3..967fd8fded 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -75,8 +75,7 @@ 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. + // In-build calls from local docker (e.g. the indexer) reach the dev webapp via this host allowedHosts: ["host.docker.internal"], }, build: { diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 81417db75e..a77890930b 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2271,9 +2271,8 @@ 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. + /// Encrypted build-time env vars for pre-bundled (fromBundle) deploys, as an + /// EncryptedSecretValue envelope. Cleared when the deployment reaches a terminal status. buildEnvVars Json? status WorkerDeploymentStatus @default(PENDING) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 9afadb0ab4..6211c1ad0a 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -690,7 +690,7 @@ export class CliApiClient { ); } - // Best-effort cancel (204 on success, no body) — callers may ignore failures. + // 204 on success, no body async cancelDeployment(deploymentId: string, reason?: string) { if (!this.accessToken) { throw new Error("cancelDeployment: No access token"); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 7bbf7e34a4..8067bf7c55 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -105,10 +105,7 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; -// 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. +// Pre-checks of the server-enforced limits, to fail before uploading anything const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; const BUILD_ENV_VARS_MAX_KEYS = 200; @@ -363,9 +360,6 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } 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, @@ -660,8 +654,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { }); } -// 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). +// Shared tail of the standard deploy path (after bundling) and --from-bundle. async function buildAndFinalizeDeployment({ apiClient, projectId, @@ -1300,18 +1293,12 @@ async function handleNativeBuildServerDeploy({ 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. + // --local-bundle: install + bundling happen locally; the server only runs the container build. 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 — - // local build-tuning flags are not forwarded. Be honest about ignoring them. const ignoredBuildFlags = [ options.compression !== "zstd" && "--compression", options.cacheCompression !== "zstd" && "--cache-compression", @@ -1370,11 +1357,7 @@ async function handleNativeBuildServerDeploy({ bundleManifest = buildManifest; bundleOutputPath = destination.path; - // 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. 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. + // Extensions can set undefined values at runtime despite the manifest type bundleBuildEnvVars = Object.fromEntries( Object.entries(buildManifest.build.env ?? {}).filter( (entry): entry is [string, string] => typeof entry[1] === "string" @@ -1401,10 +1384,7 @@ async function handleNativeBuildServerDeploy({ 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. + // Sync BEFORE init: init enqueues the build synchronously, so a post-init sync races a fast build if (!options.skipSyncEnvVars) { const childVars = buildManifest.deploy.sync?.env ?? {}; const parentVars = buildManifest.deploy.sync?.parentEnv ?? {}; @@ -1467,11 +1447,8 @@ async function handleNativeBuildServerDeploy({ logger.debug("Artifact created", { artifactKey }); - // Version-skew guard: an older server that does not know the deployment_bundle - // artifact type silently stores the upload as a plain source context, and the - // remote build would then try to install and bundle an already-bundled directory. - // The bundle-specific key prefix doubles as the ack that the server understood - // the type, independent of whether any build env vars are sent later. + // The bundle key prefix is the ack that the server understood the deployment_bundle + // type; an older server silently stores the upload as a plain source context. if (options.localBundle && !artifactKey.startsWith("bundles/")) { $deploymentSpinner.stop("Failed creating deployment artifact"); log.error( @@ -1539,8 +1516,7 @@ async function handleNativeBuildServerDeploy({ userId, gitMeta, type: config.features.run_engine_v2 ? "MANAGED" : "V1", - // Deliberately config.runtime (not the resolved manifest runtime) so the persisted - // value is identical to classic native deploys. + // config.runtime (not the manifest runtime) to match classic native deploys runtime: config.runtime, isNativeBuild: true, artifactKey, @@ -1591,18 +1567,15 @@ async function handleNativeBuildServerDeploy({ return; } - // 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. Deliberately after - // the outcome=existing return: a reused deployment builds nothing, so no ack is due. + // No ack for sent build env vars means an older server stripped them; fail fast. + // After the outcome=existing return: a reused deployment builds nothing. if ( options.localBundle && bundleBuildEnvVars && 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. + // Best-effort cancel so the deployment does not linger until the queue timeout const [cancelError] = await tryCatch( apiClient.cancelDeployment(deployment.id, "Build environment variables were not stored") ); @@ -1923,12 +1896,8 @@ export function verifyDirectory(dir: string, projectPath: string) { } } -// 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 -// and the deployment record (including the build-arg values, stored encrypted there). +// Runs only the container build from a pre-built bundle dir, skipping config loading +// entirely. Attach mode is the supported flow (build server); fresh-init is for testing. async function handleFromBundleDeploy({ bundleDir, options, @@ -1975,8 +1944,7 @@ async function handleFromBundleDeploy({ const bundleManifest = manifestResult.data; - // Match the other deploy paths' promise: --dry-run never touches the server. - // Exit after the manifest is validated, before any branch/deployment calls. + // --dry-run must never touch the server if (options.dryRun) { logger.info(`Dry run complete. Validated bundle at ${bundlePath}`); return; @@ -1992,8 +1960,7 @@ 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. + // In attach mode the branch env already exists if (options.env === "preview" && branch && !existingDeploymentId) { await upsertBranch({ accessToken: auth.accessToken, @@ -2017,10 +1984,7 @@ 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. + // In attach mode the build-arg values are stored encrypted on the deployment let buildEnvVars: Record | undefined; if (existingDeploymentId) { @@ -2035,16 +1999,10 @@ async function handleFromBundleDeploy({ 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. 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 c35a5875dc..efd79b5aef 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.test.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -20,12 +20,10 @@ describe("createBundleArchive", () => { }); 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 {}"); - // 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"); @@ -35,8 +33,6 @@ describe("createBundleArchive", () => { 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(); @@ -51,7 +47,6 @@ describe("createBundleArchive", () => { ].sort() ); - // Nested dot-dir contents survive const skill = await readFile( join(extractDir, ".trigger", "skills", "my-skill", "SKILL.md"), "utf-8" @@ -62,9 +57,7 @@ describe("createBundleArchive", () => { it("excludes only .DS_Store — node_modules paths must survive", async () => { await writeFile(join(bundleDir, "build.json"), "{}"); await writeFile(join(bundleDir, ".DS_Store"), "junk"); - // 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). + // Under npx the controller entry points live beneath a node_modules segment const controllerDir = join( bundleDir, ".npm", @@ -76,7 +69,6 @@ describe("createBundleArchive", () => { ); 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"); diff --git a/packages/cli-v3/src/deploy/bundleArchive.ts b/packages/cli-v3/src/deploy/bundleArchive.ts index d71998dc82..f53c820df8 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.ts @@ -2,19 +2,12 @@ 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. 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. +// The bundle dir is generated build output, so the usual source ignores (dist, +// node_modules, ...) would strip load-bearing files: under npx the controller +// entry points live beneath a node_modules path segment. const BUNDLE_IGNORES = ["**/.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. - */ +// Bundle contents land at the archive root; the build server extracts without stripping export async function createBundleArchive(bundleDir: string, outputPath: string) { logger.debug("Creating bundle archive", { bundleDir, outputPath }); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 7e256e41f9..5e4b820aae 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -758,8 +758,7 @@ 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. + // Ack that buildEnvVars were stored; absence on an older server is a client-side hard error buildEnvVarsStored: z.boolean().optional(), }); @@ -808,11 +807,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. + // The artifact is a pre-built bundle; the build server only runs 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. + // Build-time env var values for fromBundle deploys, stored encrypted on the deployment buildEnvVars: z.record(z.string()).optional(), }).superRefine((data, ctx) => { if (data.force && !data.externalId) { @@ -943,8 +940,7 @@ 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. +// Secret material, deliberately kept off GetDeploymentResponseBody export const GetDeploymentBuildEnvVarsResponseBody = z.object({ variables: z.record(z.string()), });