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 c74c66a222..2258edb951 100644 --- a/apps/webapp/app/routes/api.v1.artifacts.ts +++ b/apps/webapp/app/routes/api.v1.artifacts.ts @@ -62,6 +62,9 @@ export async function action({ request }: ActionFunctionArgs) { case "deployment_context": errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`; break; + case "deployment_bundle": + errorMessage = `Bundle size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Reach out to us if you are seeing this error consistently.`; + break; default: body.data.type satisfies never; errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`; diff --git a/apps/webapp/app/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..561f8daf65 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -0,0 +1,120 @@ +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, + }); + } + + // 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) { + 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 } + ); + } + + 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( + { + error: "The stored build environment variables could not be decrypted. Retry the deploy.", + }, + { status: 500 } + ); + } + + 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 39988e1d13..ffa6d124e1 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -37,7 +37,10 @@ export async function action({ request, params }: ActionFunctionArgs) { const service = new InitializeDeploymentService(); try { - const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data); + const { deployment, imageRef, eventStream, buildEnvVarsStored } = await service.call( + authenticatedEnv, + body.data + ); const responseBody: InitializeDeploymentResponseBody = { id: deployment.friendlyId, @@ -49,6 +52,8 @@ export async function action({ request, params }: ActionFunctionArgs) { imageTag: imageRef, imagePlatform: deployment.imagePlatform, eventStream, + // Only ack when we actually stored vars; older CLIs ignore this field. + ...(buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), }; return json(responseBody, { status: 200 }); diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bded0b9206..78466665a8 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -996,6 +996,7 @@ export async function enqueueBuild( options: { skipPromotion?: boolean; configFilePath?: string; + fromBundle?: boolean; } ) { if (!client) return undefined; @@ -1136,6 +1137,13 @@ export function isCloud(): boolean { return true; } + // PR preview environments are cloud-style installs running against the + // cloud's staging services. Without this, anything gated on the billing + // client silently no-ops there (e.g. remote builds never get enqueued). + if (env.LOGIN_ORIGIN.endsWith(".triggerlabs.dev")) { + return true; + } + if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") { return true; } diff --git a/apps/webapp/app/v3/services/artifacts.server.ts b/apps/webapp/app/v3/services/artifacts.server.ts index 9e82af5123..fa5996a247 100644 --- a/apps/webapp/app/v3/services/artifacts.server.ts +++ b/apps/webapp/app/v3/services/artifacts.server.ts @@ -24,16 +24,21 @@ 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. + deployment_bundle: "bundles", } 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/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 66a17bb9f6..07e4e4f5d7 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,9 +1,10 @@ import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; import { logger, tryCatch } from "@trigger.dev/core/v3"; -import type { - BackgroundWorker, - PrismaClientOrTransaction, - WorkerDeployment, +import { + Prisma, + type BackgroundWorker, + type PrismaClientOrTransaction, + type WorkerDeployment, } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { type TaskMetadataCache } from "~/services/taskMetadataCache.server"; @@ -288,6 +289,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { name: error.name, message: error.message, }, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index f726bba3d6..619f79ac31 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -1,7 +1,7 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService } from "./baseService.server"; import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; -import { type WorkerDeployment, type Project } from "@trigger.dev/database"; +import { Prisma, type WorkerDeployment, type Project } from "@trigger.dev/database"; import { BuildServerMetadata, logger, @@ -227,6 +227,8 @@ export class DeploymentService extends BaseService { status: "CANCELED", canceledAt: new Date(), canceledReason: data?.canceledReason, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }), (error) => ({ @@ -339,6 +341,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 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 98ef7a73bd..842c977a66 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -1,4 +1,5 @@ import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; +import { Prisma } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; @@ -75,6 +76,8 @@ export class FinalizeDeploymentService extends BaseService { deployedAt: new Date(), // Only add the digest, if any imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index abb99082dd..0034f9f3b5 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -6,6 +6,7 @@ import { import { customAlphabet } from "nanoid"; import { env } from "~/env.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { encryptSecret } from "~/services/secrets/secretStore.server"; import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; @@ -20,6 +21,11 @@ import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); +// Limits for fromBundle build env vars — they expand into --build-arg values, so +// keep them well under exec argv limits while staying generous for env vars. +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; + export class InitializeDeploymentService extends BaseService { public async call( environment: AuthenticatedEnvironment, @@ -56,6 +62,7 @@ export class InitializeDeploymentService extends BaseService { return { deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", + buildEnvVarsStored: false, }; } @@ -172,6 +179,36 @@ export class InitializeDeploymentService extends BaseService { } : undefined; + // Encrypt fromBundle build env vars for storage on the deployment row. Only + // meaningful for pre-bundled deploys; cleared on every terminal transition. + let encryptedBuildEnvVars: Awaited> | undefined; + + if ( + payload.isNativeBuild && + payload.fromBundle && + payload.buildEnvVars && + Object.keys(payload.buildEnvVars).length > 0 + ) { + const buildEnvVars = payload.buildEnvVars; + + const keyCount = Object.keys(buildEnvVars).length; + if (keyCount > BUILD_ENV_VARS_MAX_KEYS) { + throw new ServiceValidationError( + `Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).` + ); + } + + const serialized = JSON.stringify(buildEnvVars); + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) { + throw new ServiceValidationError( + `Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.` + ); + } + + encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized); + } + const buildServerMetadata: BuildServerMetadata | undefined = payload.isNativeBuild || payload.buildId ? { @@ -183,6 +220,7 @@ export class InitializeDeploymentService extends BaseService { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, skipEnqueue: payload.skipEnqueue, + fromBundle: payload.fromBundle, } : {}), } @@ -247,6 +285,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -276,6 +315,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", { @@ -310,6 +350,7 @@ export class InitializeDeploymentService extends BaseService { deployment, imageRef: deployment.imageReference ?? "", eventStream, + buildEnvVarsStored: encryptedBuildEnvVars !== undefined, }; }); } diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index 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/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index b008ae04ed..c075c786bc 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -34,6 +34,9 @@ export default defineConfig({ clientFiles: ["./app/entry.client.tsx", "./app/root.tsx", "./app/components/**/*.tsx"], ssrFiles: ["./app/entry.server.tsx", "./app/root.tsx"], }, + // Local docker builds (and the dev build-server harness) reach the dev webapp as + // host.docker.internal — e.g. the in-build indexer fetching env vars. + allowedHosts: ["host.docker.internal"], }, build: { sourcemap: true, 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 2800dd5dd2..2ed828ce97 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2133,6 +2133,10 @@ model WorkerDeployment { externalBuildData Json? buildServerMetadata Json? + /// Encrypted build-time env vars for pre-bundled (fromBundle) deploys — an + /// EncryptedSecretValue envelope of a JSON record. Cleared when the deployment + /// reaches a terminal status; only ever exists for the active build window. + buildEnvVars Json? status WorkerDeploymentStatus @default(PENDING) type WorkerDeploymentType @default(V1) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index d01062440e..e139438a4d 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -23,6 +23,7 @@ import { DevDisconnectResponseBody, EnvironmentVariableResponseBody, FailDeploymentResponseBody, + GetDeploymentBuildEnvVarsResponseBody, GetDeploymentResponseBody, GetEnvironmentVariablesResponseBody, GetLatestDeploymentResponseBody, @@ -592,6 +593,33 @@ 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"); + } + + 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 0c6eece0a5..ccbc1daef5 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -7,7 +7,7 @@ import type { DeploymentFinalizedEvent, DeploymentTriggeredVia, } from "@trigger.dev/core/v3/schemas"; -import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; +import { BuildManifest, DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; import type { Command } from "commander"; import { Option as CommandOption } from "commander"; import { join, relative, resolve } from "node:path"; @@ -19,6 +19,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 { @@ -82,6 +83,8 @@ const DeployCommandOptions = CommonCommandOptions.extend({ push: z.boolean().optional(), builder: z.string().default("trigger"), nativeBuildServer: z.boolean().default(false), + localBundle: z.boolean().default(false), + fromBundle: z.string().optional(), detach: z.boolean().default(false), plain: z.boolean().default(false), compression: z.enum(["zstd", "gzip"]).default("zstd"), @@ -94,6 +97,13 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; +// 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 ( commonOptions( @@ -232,6 +242,23 @@ export function configureDeployCommand(program: Command) { "Use the native build server for building the image" ) ) + .addOption( + new CommandOption( + "--local-bundle", + "Experimental: bundle the project locally and upload only the build output; the build server runs the container build. Useful when the remote install/bundle step doesn't work for your project setup. Implies using the native build server." + ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild"]) + ) + .addOption( + new CommandOption( + "--from-bundle ", + "Internal: build the deployment image from a pre-built bundle directory, skipping the bundling step. Implies a local build." + ) + .implies({ localBuild: true }) + .conflicts(["nativeBuildServer", "localBundle"]) + .hideHelp() + ) .addOption( new CommandOption( "--detach", @@ -298,6 +325,21 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Using project ref from env", { ref: envVars.TRIGGER_PROJECT_REF }); } + if (options.fromBundle) { + // Builds the image from a pre-built bundle directory. The bundle carries no + // trigger.config.ts source, so this path skips config loading entirely and + // drives off the bundle's build.json + the deployment record. + await handleFromBundleDeploy({ + bundleDir: options.fromBundle, + options, + dashboardUrl: authorization.dashboardUrl, + auth: authorization.auth, + existingDeploymentId: envVars.TRIGGER_EXISTING_DEPLOYMENT_ID, + projectRefOverride: options.projectRef ?? envVars.TRIGGER_PROJECT_REF, + }); + return; + } + const resolvedConfig = await loadConfig({ cwd: projectPath, overrides: { project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF }, @@ -370,6 +412,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { options, userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined, gitMeta, + branch, }); return; } @@ -439,8 +482,6 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { // which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud // and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion. const isLocalBuild = options.localBuild || !deployment.externalBuildData; - const authenticateToTriggerRegistry = options.localBuild; - const skipServerSideRegistryPush = options.localBuild; // Fail fast if we know local builds will fail if (isLocalBuild) { @@ -508,12 +549,57 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } + await buildAndFinalizeDeployment({ + apiClient: projectClient.client, + projectId: projectClient.id, + projectRef: resolvedConfig.project, + deployment, + options, + dashboardUrl: authorization.dashboardUrl, + authAccessToken: authorization.auth.accessToken, + compilationPath: destination.path, + buildEnvVars: buildManifest.build.env, + branch, + isLocalBuild, + }); +} + +// The shared "build the image and finalize the deployment" tail, used by the standard +// deploy path (after bundling) and by --from-bundle (building from a pre-built bundle). +async function buildAndFinalizeDeployment({ + apiClient, + projectId, + projectRef, + deployment, + options, + dashboardUrl, + authAccessToken, + compilationPath, + buildEnvVars, + branch, + isLocalBuild, +}: { + apiClient: CliApiClient; + projectId: string; + projectRef: string; + deployment: Deployment; + options: DeployCommandOptions; + dashboardUrl: string; + authAccessToken: string; + compilationPath: string; + buildEnvVars: Record | undefined; + branch: string | undefined; + isLocalBuild: boolean; +}) { + const authenticateToTriggerRegistry = options.localBuild; + const skipServerSideRegistryPush = options.localBuild; + const version = deployment.version; - const rawDeploymentLink = `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`; - const rawTestLink = `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project - }/test?environment=${options.env === "prod" ? "prod" : "stg"}`; + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`; + const rawTestLink = `${dashboardUrl}/projects/v3/${projectRef}/test?environment=${ + options.env === "prod" ? "prod" : "stg" + }`; const deploymentLink = cliLink("View deployment", rawDeploymentLink); const testLink = cliLink("Test tasks", rawTestLink); @@ -550,15 +636,15 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { externalBuildId: deployment.externalBuildData?.buildId, externalBuildToken: deployment.externalBuildData?.buildToken, externalBuildProjectId: deployment.externalBuildData?.projectId, - projectId: projectClient.id, - projectRef: resolvedConfig.project, - apiUrl: projectClient.client.apiURL, - apiKey: projectClient.client.accessToken!, - apiClient: projectClient.client, + projectId, + projectRef, + apiUrl: apiClient.apiURL, + apiKey: apiClient.accessToken!, + apiClient, branchName: branch, - authAccessToken: authorization.auth.accessToken, - compilationPath: destination.path, - buildEnvVars: buildManifest.build.env, + authAccessToken, + compilationPath, + buildEnvVars, compression: options.compression, cacheCompression: options.cacheCompression, compressionLevel: options.compressionLevel, @@ -593,7 +679,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const buildFailed = !warnings.ok || !buildResult.ok; if (buildFailed && canShowLocalBuildHint) { - const providerStatus = await projectClient.client.getRemoteBuildProviderStatus(); + const providerStatus = await apiClient.getRemoteBuildProviderStatus(); if (providerStatus.success && providerStatus.data.status === "degraded") { prettyWarning(providerStatus.data.message + "\n"); @@ -602,7 +688,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!warnings.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: warnings.summary }, buildResult.logs, @@ -616,7 +702,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!buildResult.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: buildResult.error }, buildResult.logs, @@ -627,11 +713,11 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { throw new SkipLoggingError("Failed to build image"); } - const getDeploymentResponse = await projectClient.client.getDeployment(deployment.id); + const getDeploymentResponse = await apiClient.getDeployment(deployment.id); if (!getDeploymentResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", message: getDeploymentResponse.error }, buildResult.logs, @@ -649,7 +735,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { : undefined; await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", @@ -674,7 +760,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } - const finalizeResponse = await projectClient.client.finalizeDeployment( + const finalizeResponse = await apiClient.finalizeDeployment( deployment.id, { imageDigest: buildResult.digest, @@ -699,7 +785,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!finalizeResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "FinalizeError", message: finalizeResponse.error }, buildResult.logs, @@ -754,18 +840,18 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { TRIGGER_DEPLOYMENT_VERSION: version, TRIGGER_VERSION: version, TRIGGER_DEPLOYMENT_SHORT_CODE: deployment.shortCode, - TRIGGER_DEPLOYMENT_URL: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - TRIGGER_TEST_URL: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project + TRIGGER_DEPLOYMENT_URL: `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`, + TRIGGER_TEST_URL: `${dashboardUrl}/projects/v3/${ + projectRef }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, }, outputs: { deploymentVersion: version, workerVersion: version, deploymentShortCode: deployment.shortCode, - deploymentUrl: `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`, - testUrl: `${authorization.dashboardUrl}/projects/v3/${ - resolvedConfig.project + deploymentUrl: `${dashboardUrl}/projects/v3/${projectRef}/deployments/${deployment.shortCode}`, + testUrl: `${dashboardUrl}/projects/v3/${ + projectRef }/test?environment=${options.env === "prod" ? "prod" : "stg"}`, needsPromotion: options.skipPromotion ? "true" : "false", }, @@ -1028,6 +1114,7 @@ async function handleNativeBuildServerDeploy({ dashboardUrl, userId, gitMeta, + branch, }: { apiClient: CliApiClient; config: Awaited>; @@ -1035,23 +1122,166 @@ 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; + // 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", + 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; + } + + 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. + 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"); + + 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}`); + 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(); $deploymentSpinner.start("Preparing deployment files"); - await createContextArchive(config.workspaceDir, archivePath); + if (bundleOutputPath) { + await createBundleArchive(bundleOutputPath, archivePath); + } else { + await createContextArchive(config.workspaceDir, archivePath); + } const archiveSize = await getArchiveSize(archivePath); const sizeMB = (archiveSize / 1024 / 1024).toFixed(2); $deploymentSpinner.message(`Deployment files ready (${sizeMB} MB)`); const artifactResult = await apiClient.createArtifact({ - type: "deployment_context", + type: options.localBundle ? "deployment_bundle" : "deployment_context", contentType: "application/gzip", contentLength: archiveSize, }); @@ -1117,16 +1347,23 @@ async function handleNativeBuildServerDeploy({ : undefined; const initializeDeploymentResult = await apiClient.initializeDeployment({ - contentHash: "-", + contentHash: bundleManifest?.contentHash ?? "-", 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. runtime: config.runtime, isNativeBuild: true, artifactKey, skipPromotion: options.skipPromotion, configFilePath, triggeredVia: getTriggeredVia(), + fromBundle: options.localBundle ? true : undefined, + buildEnvVars: + options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0 + ? bundleBuildEnvVars + : undefined, }); if (!initializeDeploymentResult.success) { @@ -1137,6 +1374,38 @@ 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 + ) { + // 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( + 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" @@ -1448,3 +1717,161 @@ 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 +// and the deployment record (including the build-arg values, stored encrypted there). +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; + + 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 (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, + projectRef, + env: options.env, + branch, + profile: options.profile, + }); + + if (!projectClient) { + 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. 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." + ); + } + + 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.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts new file mode 100644 index 0000000000..c35a5875dc --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -0,0 +1,114 @@ +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 {}"); + // 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"); + + 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", + ].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 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). + 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"); + + 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 0000000000..d71998dc82 --- /dev/null +++ b/packages/cli-v3/src/deploy/bundleArchive.ts @@ -0,0 +1,47 @@ +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. +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. + */ +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 3b57395b29..0872c269be 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -580,6 +580,7 @@ export const BuildServerMetadata = z.object({ skipPromotion: z.boolean().optional(), configFilePath: z.string().optional(), skipEnqueue: z.boolean().optional(), + fromBundle: z.boolean().optional(), }); export type BuildServerMetadata = z.infer; @@ -646,7 +647,7 @@ export const UpsertBranchResponseBody = z.object({ export type UpsertBranchResponseBody = z.infer; export const CreateArtifactRequestBody = z.object({ - type: z.enum(["deployment_context"]).default("deployment_context"), + type: z.enum(["deployment_context", "deployment_bundle"]).default("deployment_context"), contentType: z.string().default("application/gzip"), contentLength: z.number().optional(), }); @@ -679,6 +680,9 @@ export const InitializeDeploymentResponseBody = z.object({ }), }) .optional(), + // Ack that the server accepted and stored buildEnvVars from the request. The CLI + // treats its absence (older server) as a hard error when it sent non-empty vars. + buildEnvVarsStored: z.boolean().optional(), }); export type InitializeDeploymentResponseBody = z.infer; @@ -704,6 +708,8 @@ type NativeBuildOutput = BaseOutput & { artifactKey?: string; configFilePath?: string; skipEnqueue?: boolean; + fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -712,6 +718,8 @@ type NonNativeBuildOutput = BaseOutput & { artifactKey?: never; configFilePath?: never; skipEnqueue?: never; + fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -720,6 +728,12 @@ 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(), + // Build-time env var values for fromBundle deploys. Stored encrypted on the + // deployment and cleared once the deployment reaches a terminal status. + buildEnvVars: z.record(z.string()).optional(), }); export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform( @@ -727,7 +741,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 }; } ); @@ -832,6 +854,16 @@ export const GetDeploymentResponseBody = z.object({ export type GetDeploymentResponseBody = z.infer; +// Response of the dedicated build-env-vars endpoint (secret material — deliberately +// kept off GetDeploymentResponseBody). Empty record when none were stored. +export const GetDeploymentBuildEnvVarsResponseBody = z.object({ + variables: z.record(z.string()), +}); + +export type GetDeploymentBuildEnvVarsResponseBody = z.infer< + typeof GetDeploymentBuildEnvVarsResponseBody +>; + export const GetLatestDeploymentResponseBody = GetDeploymentResponseBody.omit({ worker: true, });