diff --git a/.changeset/local-bundle-deploy.md b/.changeset/local-bundle-deploy.md new file mode 100644 index 00000000000..cb9cfdfee17 --- /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 that runs the install and bundling steps on your machine and uploads only the build output; the image is still built remotely. Useful when your project's install step needs tooling or credentials that only exist locally. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 0b86f83b492..6c7452ace0e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -816,6 +816,19 @@ const EnvironmentSchema = z .number() .int() .default(60 * 1000 * 15), // 15 minutes + DEPLOYMENT_CONTEXT_ARTIFACT_SIZE_LIMIT_BYTES: z.coerce + .number() + .int() + .default(100 * 1024 * 1024), // 100MB + DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES: z.coerce + .number() + .int() + .default(100 * 1024 * 1024), // 100MB + DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES: z.coerce + .number() + .int() + .default(128 * 1024), // 128KB + DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS: z.coerce.number().int().default(400), // When enabled, reject deploys made by v3 CLI versions (i.e. payloads that // omit the `type` field). v4 CLI versions always send `type` ("MANAGED" or "V1"), diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index a706f9e04ef..12c2a10a9ea 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/routes/api.v1.deployments.$deploymentId.build-env-vars.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts new file mode 100644 index 00000000000..739046df13b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -0,0 +1,98 @@ +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 { 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"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +// Secret material, deliberately separate from the main GET deployment endpoint. +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + try { + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); + + if (!authResult.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: authResult.error }, { status: authResult.status }); + } + + const authenticatedEnv = authResult.authentication.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, + }); + + // 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, + }); + } + + if (!deployment.buildEnvVars) { + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + // 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) { + logger.error("Stored build env vars are not a valid encrypted envelope", { + deploymentId, + environmentId: authenticatedEnv.id, + }); + return json( + { error: "The stored build environment variables could not be read. Retry the deploy." }, + { status: 500 } + ); + } + + const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data); + const variables = z.record(z.string()).parse(JSON.parse(decrypted)); + + 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/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 01b1c7d4972..2b7e8fb3d4f 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; @@ -1235,6 +1236,10 @@ export function isCloud(): boolean { return true; } + if (env.LOGIN_ORIGIN?.endsWith(".triggerlabs.dev")) { + return true; + } + if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") { return true; } diff --git a/apps/webapp/app/v3/services/artifacts.server.ts b/apps/webapp/app/v3/services/artifacts.server.ts index 9e82af51234..2d1ef190978 100644 --- a/apps/webapp/app/v3/services/artifacts.server.ts +++ b/apps/webapp/app/v3/services/artifacts.server.ts @@ -24,16 +24,19 @@ const objectStoreClient = const artifactKeyPrefixByType = { deployment_context: "deployments", + // The key prefix is the one bundle signal that survives schema skew + deployment_bundle: "bundles", } as const; const artifactBytesSizeLimitByType = { - deployment_context: 100 * 1024 * 1024, // 100MB + deployment_context: env.DEPLOYMENT_CONTEXT_ARTIFACT_SIZE_LIMIT_BYTES, + deployment_bundle: env.DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES, } 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/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 305ee45ce25..d09707a0e83 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,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { name: error.name, message: error.message, }, + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index c67d7778568..7a891ae4f61 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,7 @@ export class DeploymentService extends BaseService { status: "CANCELED", canceledAt: new Date(), canceledReason: data?.canceledReason, + buildEnvVars: Prisma.DbNull, }, }), (error) => ({ @@ -339,6 +340,7 @@ export class DeploymentService extends BaseService { options: { skipPromotion?: boolean; configFilePath?: string; + fromBundle?: boolean; } ) { return fromPromise( diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index 87b7618d76d..cb5c622b7b2 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,7 @@ export class FailDeploymentService extends BaseService { status: "FAILED", failedAt: new Date(), errorData: params.error, + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/finalizeDeployment.server.ts b/apps/webapp/app/v3/services/finalizeDeployment.server.ts index 0595cee1e2b..51f5b1e37c4 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,7 @@ export class FinalizeDeploymentService extends BaseService { deployedAt: new Date(), // Only add the digest, if any imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined, + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index c5b01c6084b..ee55d8bd8d6 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"; @@ -268,6 +269,38 @@ export class InitializeDeploymentService extends BaseService { } : undefined; + 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 > env.DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS) { + throw new ServiceValidationError( + `Build environment variable count (${keyCount}) exceeds the allowed limit of ${env.DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS}. Reach out to us if you are seeing this error consistently.` + ); + } + + const serialized = JSON.stringify(buildEnvVars); + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (serializedBytes > env.DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES) { + const sizeKB = parseFloat((serializedBytes / 1024).toFixed(1)); + const limitKB = parseFloat( + (env.DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES / 1024).toFixed(1) + ); + throw new ServiceValidationError( + `Build environment variables size (${sizeKB} KB) exceeds the allowed limit of ${limitKB} KB. Reach out to us if you are seeing this error consistently.` + ); + } + + encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized); + } + const buildServerMetadata: BuildServerMetadata | undefined = payload.isNativeBuild || payload.buildId ? { @@ -279,6 +312,7 @@ export class InitializeDeploymentService extends BaseService { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, skipEnqueue: payload.skipEnqueue, + fromBundle: payload.fromBundle, } : {}), } @@ -343,6 +377,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -373,6 +408,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", { diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index fa3de698e36..5e417a7863b 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,7 @@ export class TimeoutDeploymentService extends BaseService { status: "TIMED_OUT", failedAt: new Date(), errorData: { message: errorMessage, name: "TimeoutError" }, + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index 56ddae17c02..967fd8fded3 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -75,6 +75,8 @@ export default defineConfig({ clientFiles: ["./app/entry.client.tsx", "./app/root.tsx", "./app/components/**/*.tsx"], ssrFiles: ["./app/entry.server.tsx", "./app/root.tsx"], }, + // In-build calls from local docker (e.g. the indexer) reach the dev webapp via this host + allowedHosts: ["host.docker.internal"], }, build: { sourcemap: true, diff --git a/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql b/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql new file mode 100644 index 00000000000..49e62e6e20d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN IF NOT EXISTS "buildEnvVars" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 470f2c251c4..a77890930b1 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2271,6 +2271,9 @@ model WorkerDeployment { externalBuildData Json? buildServerMetadata Json? + /// 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) type WorkerDeploymentType @default(V1) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index fba2e52e1ee..8b9fd56eb1c 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 7afa06982ae..74052486c13 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,6 +24,7 @@ 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 { @@ -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"), @@ -248,6 +251,23 @@ export function configureDeployCommand(program: Command) { "Use the native build server for building the image" ) ) + .addOption( + new CommandOption( + "--local-bundle", + "Experimental: install and bundle locally, upload only the build output, and build the image remotely. Implies using the native build server." + ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild"]) + ) + .addOption( + new CommandOption( + "--from-bundle ", + "Internal: build the image from a pre-built bundle directory. Implies a local build." + ) + .implies({ localBuild: true }) + .conflicts(["nativeBuildServer", "localBundle"]) + .hideHelp() + ) .addOption( new CommandOption( "--detach", @@ -335,6 +355,18 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Using project ref from env", { ref: envVars.TRIGGER_PROJECT_REF }); } + if (options.fromBundle) { + 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 }, @@ -413,6 +445,19 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } + if (options.localBundle) { + await handleLocalBundleDeploy({ + apiClient: projectClient.client, + config: resolvedConfig, + dashboardUrl: authorization.dashboardUrl, + options, + userId: userIdForDeploy(authorization), + gitMeta, + branch, + }); + return; + } + if (options.nativeBuildServer) { await handleNativeBuildServerDeploy({ apiClient: projectClient.client, @@ -1635,3 +1680,993 @@ export function verifyDirectory(dir: string, projectPath: string) { throw new Error(`Directory "${dir}" not found at ${projectPath}`); } } + +// --local-bundle: install + bundling happen locally, only the build output is +// uploaded, and the build server runs just the container build from it. +async function handleLocalBundleDeploy({ + apiClient, + options, + config, + dashboardUrl, + userId, + gitMeta, + branch, +}: { + apiClient: CliApiClient; + config: Awaited>; + dashboardUrl: string; + 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`); + + 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); + + // 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 }); + + 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; + } + + const bundleManifest = buildManifest; + const bundleOutputPath = destination.path; + + // Extensions can set undefined values at runtime despite the manifest type + const bundleBuildEnvVars = Object.fromEntries( + Object.entries(buildManifest.build.env ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); + + if (options.dryRun) { + logger.info(`Dry run complete. View the built bundle at ${destination.path}`); + return; + } + + // Sync BEFORE init: init enqueues the build synchronously, so a post-init sync races a fast build + 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 (!options.skipSyncEnvVars) { + 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"); + } + } else if (hasVarsToSync) { + logger.log( + "Skipping syncing env vars. The environment variables in your project have changed, but the --skip-sync-env-vars flag was provided." + ); + } + + const $deploymentSpinner = spinner(); + $deploymentSpinner.start("Preparing deployment files"); + + await createBundleArchive(bundleOutputPath, 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_bundle", + contentType: "application/gzip", + contentLength: archiveSize, + }); + + if (!artifactResult.success) { + $deploymentSpinner.stop("Failed creating deployment artifact"); + log.error(chalk.bold(chalkError(artifactResult.error))); + throw new OutroCommandError(`Deployment failed`); + } + + const { artifactKey, uploadUrl, uploadFields } = artifactResult.data; + + logger.debug("Artifact created", { artifactKey }); + + // Defense in depth: current older servers already reject the deployment_bundle + // type at createArtifact; this catches a server that accepts it but returns a + // non-bundle key, which would make the remote build treat the bundle as source. + if (!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)); + + if (readError) { + $deploymentSpinner.stop("Failed reading deployment archive"); + log.error(chalk.bold(chalkError(readError.message))); + throw new OutroCommandError(`Deployment failed`); + } + + const formData = new FormData(); + + for (const [key, value] of Object.entries(uploadFields)) { + formData.append(key, value); + } + + const blob = new Blob([new Uint8Array(fileBuffer)], { type: "application/gzip" }); + formData.append("file", blob, "deployment.tar.gz"); + + const [uploadError, uploadResponse] = await tryCatch( + fetch(uploadUrl, { + method: "POST", + body: formData, + }) + ); + + if (uploadError || !uploadResponse?.ok) { + $deploymentSpinner.stop("Failed to upload deployment files"); + log.error( + chalk.bold( + chalkError( + `${uploadError?.message} (${uploadResponse?.statusText} ${uploadResponse?.status})` + ) + ) + ); + throw new OutroCommandError(`Deployment failed`); + } + + const [unlinkError] = await tryCatch(unlink(archivePath)); + if (unlinkError) { + logger.debug("Failed to delete deployment artifact file", { archivePath, error: unlinkError }); + } + + $deploymentSpinner.message("Deployment files uploaded"); + + const configFilePath = + config.configFile !== undefined + ? relative(config.workspaceDir, config.configFile).replace(/\\/g, "/") + : undefined; + + const initializeDeploymentResult = await apiClient.initializeDeployment({ + contentHash: bundleManifest.contentHash, + userId, + gitMeta, + type: config.features.run_engine_v2 ? "MANAGED" : "V1", + // config.runtime (not the manifest runtime) to match classic native deploys + runtime: config.runtime, + isNativeBuild: true, + artifactKey, + skipPromotion: options.skipPromotion, + configFilePath, + triggeredVia: getTriggeredVia(), + externalId: options.externalId, + force: options.force, + fromBundle: true, + buildEnvVars: Object.keys(bundleBuildEnvVars).length > 0 ? bundleBuildEnvVars : undefined, + }); + + if (!initializeDeploymentResult.success) { + $deploymentSpinner.stop("Failed to initialize deployment"); + log.error(chalk.bold(chalkError(initializeDeploymentResult.error))); + throw new OutroCommandError(`Deployment failed`); + } + + 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; + } + + const exposedDeploymentLink = isLinksSupported + ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) + : chalk.bold(rawDeploymentLink); + $deploymentSpinner.stop("Deployment initialized"); + log.info(`View deployment: ${exposedDeploymentLink}`); + + warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId); + + setDeploymentGithubActionsOutput({ + version: deployment.version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: options.skipPromotion, + }); + + if (options.detach) { + outro(`Version ${deployment.version} is being deployed`); + return; + } + + const { eventStream } = deployment; + + if (!eventStream) { + log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); + + outro(`Version ${deployment.version} is being deployed`); + + return process.exit(0); + } + + const $queuedSpinner = spinner(); + $queuedSpinner.start("Build queued"); + + const abortController = new AbortController(); + + const s2 = new S2({ accessToken: eventStream.s2.accessToken }); + const basin = s2.basin(eventStream.s2.basin); + const stream = basin.stream(eventStream.s2.stream); + + const [readSessionError, readSession] = await tryCatch( + stream.readSession( + { + start: { from: { seqNum: 0 }, clamp: true }, + stop: { waitSecs: 60 * 20 }, // 20 minutes + }, + { signal: abortController.signal } + ) + ); + + if (readSessionError) { + $queuedSpinner.stop("Failed to query build progress"); + log.warn(`Failed streaming build logs, open the deployment in the dashboard to view the logs`); + + outro( + `Version ${deployment.version} is being deployed ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + + return process.exit(0); + } + + let finalDeploymentEvent: DeploymentFinalizedEvent["data"] | undefined; + let queuedSpinnerStopped = false; + + for await (const record of readSession) { + const decoded = record.body; + const result = DeploymentEventFromString.safeParse(decoded); + if (!result.success) { + logger.debug("Failed to parse deployment event, skipping", { + error: result.error, + record: decoded, + }); + continue; + } + + const event = result.data; + + switch (event.type) { + case "log": { + if (record.seqNum === 0) { + $queuedSpinner.stop("Build started"); + console.log("│"); + queuedSpinnerStopped = true; + } + + const formattedTimestamp = chalkGrey( + new Date(record.timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }) + ); + + const { level, message } = event.data; + const formattedMessage = + level === "error" + ? chalk.bold(chalkError(message)) + : level === "warn" + ? chalkWarning(message) + : level === "debug" + ? chalkGrey(message) + : message; + + // We use console.log here instead of clack's logger as the current version does not support changing the line spacing. + // And the logs look verbose with the default spacing. + // We cannot upgrade because the newer versions introduced some weird issues with the spinner. + // Ideally, we'd use clack's `taskLog` to only show the recent n lines of logs as they are streamed, but that also seems brittle + // and has some issues with cursor movements/clearing lines that it shouldn't clear. + // We can revisit this on future versions of `@clack/prompts`. + console.log(`│ ${formattedTimestamp} ${formattedMessage}`); + break; + } + case "finalized": { + finalDeploymentEvent = event.data; + abortController.abort(); // stop the stream + break; + } + default: { + event satisfies never; + logger.debug("Unknown deployment event, skipping", { event }); + continue; + } + } + } + + if (!queuedSpinnerStopped && !finalDeploymentEvent) { + // unlikely that it happens in practice, only in rare corner cases + // the timeout would kick in earlier if the build server fails to dequeue the build + + $queuedSpinner.stop("Log stream stopped"); + + log.error("Failed dequeueing build, please try again shortly"); + + throw new OutroCommandError( + `Version ${deployment.version} ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + + if (!finalDeploymentEvent) { + log.error( + "Stopped receiving updates from the build server, please check the deployment status in the dashboard" + ); + + if (!isLinksSupported) { + log.info(`View deployment: ${rawDeploymentLink}`); + } + + throw new OutroCommandError( + `Version ${deployment.version} ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + + switch (finalDeploymentEvent.result) { + case "succeeded": { + queuedSpinnerStopped + ? log.success("Deployment completed successfully") + : $queuedSpinner.stop("Deployment completed successfully"); + + if (finalDeploymentEvent.message) { + log.success(finalDeploymentEvent.message); + } + + if (options.skipPromotion) { + log.info( + `This deployment was not automatically promoted. You can promote in the dashboard or via the promote command, e.g, \`npx trigger.dev promote ${deployment.version}\`.` + ); + } + + if (!isLinksSupported) { + log.info(`Test tasks: ${rawTestLink}`); + } + + outro( + `Version ${deployment.version} was deployed ${ + isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" + }` + ); + return process.exit(0); + } + case "failed": { + if (!queuedSpinnerStopped) { + $queuedSpinner.stop("Deployment failed"); + } + + log.error( + chalk.bold( + chalkError( + "Deployment failed" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment failed ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + case "timed_out": { + if (!queuedSpinnerStopped) { + $queuedSpinner.stop("Deployment timed out"); + } + + log.error( + chalk.bold( + chalkError( + "Deployment timed out" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment timed out ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + case "canceled": { + if (!queuedSpinnerStopped) { + $queuedSpinner.stop("Deployment was canceled"); + } + + log.error( + chalk.bold( + chalkError( + "Deployment was canceled" + + (finalDeploymentEvent.message ? `: ${finalDeploymentEvent.message}` : "") + ) + ) + ); + + throw new OutroCommandError( + `Version ${deployment.version} deployment canceled ${ + isLinksSupported ? `| ${cliLink("View deployment", rawDeploymentLink)}` : "" + }` + ); + } + default: { + // This case is only relevant in case we extend the enum in the future. + // New enum values will not be treated as errors in older cli versions. + queuedSpinnerStopped + ? log.success("Log stream finished") + : $queuedSpinner.stop("Log stream finished"); + if (finalDeploymentEvent.message) { + log.message(finalDeploymentEvent.message); + } + + if (!isLinksSupported) { + log.info(`Test tasks: ${rawTestLink}`); + } + + outro( + `Version ${deployment.version} ${ + isLinksSupported + ? `| ${cliLink("Test tasks", rawTestLink)} | ${cliLink( + "View deployment", + rawDeploymentLink + )}` + : "" + }` + ); + return process.exit(0); + } + } +} + +// Builds the image locally from the bundle and finalizes the deployment. +async function buildAndFinalizeFromBundle({ + 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, + projectRef, + env: options.env, + shortCode: deployment.shortCode, + }); + + const deploymentLink = cliLink("View deployment", rawDeploymentLink); + const testLink = cliLink("Test tasks", rawTestLink); + + const $spinner = spinner({ plain: options.plain }); + + const buildSuffix = + isLocalBuild && process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED !== "1" ? " (local)" : ""; + const deploySuffix = + isLocalBuild && process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED !== "1" ? " (local build)" : ""; + + if (options.plain) { + $spinner.start(`Building version ${version}${buildSuffix}`); + } else if (isCI) { + log.step(`Building version ${version}\n`); + } else { + if (isLinksSupported) { + $spinner.start(`Building version ${version}${buildSuffix} ${deploymentLink}`); + } else { + $spinner.start(`Building version ${version}${buildSuffix}`); + } + } + + const buildResult = await buildImage({ + isLocalBuild, + useRegistryCache: options.useRegistryCache, + noCache: !options.cache, + deploymentId: deployment.id, + deploymentVersion: deployment.version, + imageTag: deployment.imageTag, + imagePlatform: deployment.imagePlatform, + load: options.load, + contentHash: deployment.contentHash, + externalBuildId: deployment.externalBuildData?.buildId, + externalBuildToken: deployment.externalBuildData?.buildToken, + externalBuildProjectId: deployment.externalBuildData?.projectId, + projectId, + projectRef, + apiUrl: apiClient.apiURL, + apiKey: apiClient.accessToken!, + apiClient, + branchName: branch, + authAccessToken, + compilationPath, + buildEnvVars, + compression: options.compression, + cacheCompression: options.cacheCompression, + compressionLevel: options.compressionLevel, + forceCompression: options.forceCompression, + onLog: (logMessage) => { + if (options.plain || isCI) { + console.log(logMessage); + return; + } + + if (isLinksSupported) { + $spinner.message( + `Building version ${version}${buildSuffix} ${deploymentLink}: ${logMessage}` + ); + } else { + $spinner.message(`Building version ${version}${buildSuffix}: ${logMessage}`); + } + }, + // Local build options + network: options.network, + builder: options.builder, + push: options.push, + authenticateToRegistry: authenticateToTriggerRegistry, + }); + + logger.debug("Build result", buildResult); + + const warnings = checkLogsForWarnings(buildResult.logs); + + const canShowLocalBuildHint = + !isLocalBuild && process.env.TRIGGER_LOCAL_BUILD_HINT_DISABLED !== "1"; + const buildFailed = !warnings.ok || !buildResult.ok; + + if (buildFailed && canShowLocalBuildHint) { + const providerStatus = await apiClient.getRemoteBuildProviderStatus(); + + if (providerStatus.success && providerStatus.data.status === "degraded") { + prettyWarning(providerStatus.data.message + "\n"); + } + } + + if (!warnings.ok) { + await failDeploy( + apiClient, + deployment, + { name: "BuildError", message: warnings.summary }, + buildResult.logs, + $spinner, + warnings.warnings, + warnings.errors + ); + + throw new SkipLoggingError("Failed to build image"); + } + + if (!buildResult.ok) { + await failDeploy( + apiClient, + deployment, + { name: "BuildError", message: buildResult.error }, + buildResult.logs, + $spinner, + warnings.warnings + ); + + throw new SkipLoggingError("Failed to build image"); + } + + const getDeploymentResponse = await apiClient.getDeployment(deployment.id); + + if (!getDeploymentResponse.success) { + await failDeploy( + apiClient, + deployment, + { name: "DeploymentError", message: getDeploymentResponse.error }, + buildResult.logs, + $spinner + ); + + throw new SkipLoggingError(getDeploymentResponse.error); + } + + const deploymentWithWorker = getDeploymentResponse.data; + + if (!deploymentWithWorker.worker) { + const errorData = deploymentWithWorker.errorData + ? prepareDeploymentError(deploymentWithWorker.errorData) + : undefined; + + await failDeploy( + apiClient, + deployment, + { + name: "DeploymentError", + message: errorData?.message ?? "Failed to get deployment with worker", + }, + buildResult.logs, + $spinner + ); + + throw new SkipLoggingError(errorData?.message ?? "Failed to get deployment with worker"); + } + + if (options.plain) { + $spinner.message(`Deploying version ${version}${deploySuffix}`); + } else if (isCI) { + log.step(`Deploying version ${version}${deploySuffix}\n`); + } else { + if (isLinksSupported) { + $spinner.message(`Deploying version ${version}${deploySuffix} ${deploymentLink}`); + } else { + $spinner.message(`Deploying version ${version}${deploySuffix}`); + } + } + + const finalizeResponse = await apiClient.finalizeDeployment( + deployment.id, + { + imageDigest: buildResult.digest, + skipPromotion: options.skipPromotion, + skipPushToRegistry: skipServerSideRegistryPush, + }, + (logMessage) => { + if (options.plain || isCI) { + console.log(logMessage); + return; + } + + if (isLinksSupported) { + $spinner.message( + `Deploying version ${version}${deploySuffix} ${deploymentLink}: ${logMessage}` + ); + } else { + $spinner.message(`Deploying version ${version}${deploySuffix}: ${logMessage}`); + } + } + ); + + if (!finalizeResponse.success) { + await failDeploy( + apiClient, + deployment, + { name: "FinalizeError", message: finalizeResponse.error }, + buildResult.logs, + $spinner + ); + + throw new SkipLoggingError("Failed to finalize deployment"); + } + + if (options.plain) { + console.log(`Successfully deployed version ${version}${deploySuffix}`); + } else if (isCI) { + log.step(`Successfully deployed version ${version}${deploySuffix}`); + } else { + $spinner.stop(`Successfully deployed version ${version}${deploySuffix}`); + } + + const taskCount = deploymentWithWorker.worker?.tasks.length ?? 0; + + if (options.plain) { + console.log( + `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"}` + ); + + if (process.env.TRIGGER_DEPLOYMENT_LINK_OUTPUT_DISABLED !== "1") { + console.log(`Deployment: ${rawDeploymentLink}`); + console.log(`Test: ${rawTestLink}`); + } + } else { + outro( + `Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${ + isLinksSupported ? `| ${deploymentLink} | ${testLink}` : "" + }` + ); + + if (!isLinksSupported) { + console.log("View deployment"); + console.log(rawDeploymentLink); + console.log(); // new line + console.log("Test tasks"); + console.log(rawTestLink); + } + } + + if (options.saveLogs) { + const logPath = await saveLogs(deployment.shortCode, buildResult.logs); + console.log(`Full build logs have been saved to ${logPath}`); + } + + setDeploymentGithubActionsOutput({ + version, + shortCode: deployment.shortCode, + rawDeploymentLink, + rawTestLink, + needsPromotion: options.skipPromotion, + }); +} + +// 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, + 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}` + ); + } + + 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}`); + } + + const bundleManifest = manifestResult.data; + + // --dry-run must never touch the server + 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; + + if (options.env === "preview" && !branch) { + throw new Error( + "Preview deploys from a bundle require an explicit branch. Pass --branch ." + ); + } + + // In attach mode the branch env already exists + 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, + projectRef, + env: options.env, + branch, + profile: options.profile, + }); + + if (!projectClient) { + throw new Error("Failed to get project client"); + } + + // In attach mode the build-arg values are stored encrypted on the deployment + 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) { + // Extensions can set undefined values at runtime despite the manifest type + buildEnvVars = Object.fromEntries( + Object.entries(bundleManifest.build.env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); + } + + if (!existingDeploymentId) { + 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, + { + 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 buildAndFinalizeFromBundle({ + 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.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts new file mode 100644 index 00000000000..efd79b5aefd --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -0,0 +1,106 @@ +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 () => { + 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"), "*.log\n"); + 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); + 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", + ].sort() + ); + + const skill = await readFile( + join(extractDir, ".trigger", "skills", "my-skill", "SKILL.md"), + "utf-8" + ); + expect(skill).toBe("# skill"); + }); + + it("excludes only .DS_Store — node_modules paths must survive", async () => { + await writeFile(join(bundleDir, "build.json"), "{}"); + await writeFile(join(bundleDir, ".DS_Store"), "junk"); + // Under npx the controller entry points live beneath a node_modules segment + 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"); + 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", ".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 () => { + await expect(createBundleArchive(bundleDir, join(outDir, "bundle.tar.gz"))).rejects.toThrow( + /No files found/ + ); + }); +}); diff --git a/packages/cli-v3/src/deploy/bundleArchive.ts b/packages/cli-v3/src/deploy/bundleArchive.ts new file mode 100644 index 00000000000..f53c820df89 --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.ts @@ -0,0 +1,40 @@ +import { glob } from "tinyglobby"; +import * as tar from "tar"; +import { logger } from "../utilities/logger.js"; + +// 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"]; + +// 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 }); + + 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 }); +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 08dc4d9ca0a..a90430953d4 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,8 @@ type NativeBuildOutput = BaseOutput & { artifactKey?: string; configFilePath?: string; skipEnqueue?: boolean; + fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -792,6 +795,8 @@ type NonNativeBuildOutput = BaseOutput & { artifactKey?: never; configFilePath?: never; skipEnqueue?: never; + fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -800,6 +805,10 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase. artifactKey: z.string().optional(), configFilePath: z.string().optional(), skipEnqueue: z.boolean().optional().default(false), + // 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 + buildEnvVars: z.record(z.string()).optional(), }).superRefine((data, ctx) => { if (data.force && !data.externalId) { ctx.addIssue({ @@ -815,7 +824,15 @@ 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, + buildEnvVars, + ...rest + } = data; return { ...rest, isNativeBuild: false as const }; } ); @@ -921,6 +938,15 @@ export const GetDeploymentResponseBody = z.object({ export type GetDeploymentResponseBody = z.infer; +// Secret material, deliberately kept off GetDeploymentResponseBody +export const GetDeploymentBuildEnvVarsResponseBody = z.object({ + variables: z.record(z.string()), +}); + +export type GetDeploymentBuildEnvVarsResponseBody = z.infer< + typeof GetDeploymentBuildEnvVarsResponseBody +>; + export const GetLatestDeploymentResponseBody = GetDeploymentResponseBody.omit({ worker: true, });