diff --git a/.changeset/local-bundle-deploy.md b/.changeset/local-bundle-deploy.md new file mode 100644 index 00000000000..b6f88cda6bb --- /dev/null +++ b/.changeset/local-bundle-deploy.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add an experimental `--local-bundle` deploy flag: your project is installed and bundled locally (like classic deploys) and only the build output is uploaded, while the image is still built remotely. Useful when the remote build's install step doesn't work for your project setup. diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index 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..3edb3b8a81b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -0,0 +1,114 @@ +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 } + ); + } + + 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 5be291bae27..98bd151afa0 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -60,6 +60,7 @@ export async function action({ request, params }: ActionFunctionArgs) { .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], eventStream: result.eventStream, canceledDeployments: result.canceledDeployments, + ...(result.buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), } : { isPromoted: result.isPromoted }), }; diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 01b1c7d4972..48fdd227082 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,12 @@ export function isCloud(): boolean { return true; } + // Preview environments are cloud installs too; without this the billing client silently no-ops. + // Optional chaining because test suites mock the env module with partial objects. + if (env.LOGIN_ORIGIN?.endsWith(".triggerlabs.dev")) { + return true; + } + 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..85a742629ea 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_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 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..eed46d7e6d9 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -6,6 +6,7 @@ import { import { customAlphabet } from "nanoid"; import { env } from "~/env.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { encryptSecret } from "~/services/secrets/secretStore.server"; import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; @@ -29,6 +30,10 @@ import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); +// Build env vars expand into --build-arg values, so stay well under exec argv limits +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; + type DeploymentEventStream = { s2: { basin: string; @@ -44,6 +49,7 @@ export type InitializeDeploymentResult = imageRef: string; eventStream?: DeploymentEventStream; canceledDeployments?: SupersededDeployment[]; + buildEnvVarsStored?: boolean; } | { outcome: "existing"; @@ -103,6 +109,7 @@ export class InitializeDeploymentService extends BaseService { outcome: "created", deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", + buildEnvVarsStored: false, }; } @@ -268,6 +275,34 @@ 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 > 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 ? { @@ -279,6 +314,7 @@ export class InitializeDeploymentService extends BaseService { skipPromotion: payload.skipPromotion, configFilePath: payload.configFilePath, skipEnqueue: payload.skipEnqueue, + fromBundle: payload.fromBundle, } : {}), } @@ -343,6 +379,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -373,6 +410,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", { @@ -409,6 +447,7 @@ export class InitializeDeploymentService extends BaseService { imageRef: deployment.imageReference ?? "", eventStream, canceledDeployments, + buildEnvVarsStored: encryptedBuildEnvVars !== undefined, }; }); } diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index 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..6211c1ad0ac 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,33 @@ export class CliApiClient { ); } + // 204 on success, no body + 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 7afa06982ae..8067bf7c552 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"), @@ -102,6 +105,10 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; +// Pre-checks of the server-enforced limits, to fail before uploading anything +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; + export function configureDeployCommand(program: Command) { return ( commonOptions( @@ -248,6 +255,23 @@ export function configureDeployCommand(program: Command) { "Use the native build server for building the image" ) ) + .addOption( + new CommandOption( + "--local-bundle", + "Experimental: bundle the project locally and upload only the build output; the build server runs the container build. Useful when the remote install/bundle step doesn't work for your project setup. Implies using the native build server." + ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild"]) + ) + .addOption( + new CommandOption( + "--from-bundle ", + "Internal: build the deployment image from a pre-built bundle directory, skipping the bundling step. Implies a local build." + ) + .implies({ localBuild: true }) + .conflicts(["nativeBuildServer", "localBundle"]) + .hideHelp() + ) .addOption( new CommandOption( "--detach", @@ -335,6 +359,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 }, @@ -421,6 +457,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { options, userId: userIdForDeploy(authorization), gitMeta, + branch, }); return; } @@ -535,8 +572,6 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { // which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud // and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion. const isLocalBuild = options.localBuild || !deployment.externalBuildData; - const authenticateToTriggerRegistry = options.localBuild; - const skipServerSideRegistryPush = options.localBuild; // Fail fast if we know local builds will fail if (isLocalBuild) { @@ -604,11 +639,55 @@ 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, + }); +} + +// Shared tail of the standard deploy path (after bundling) and --from-bundle. +async function buildAndFinalizeDeployment({ + apiClient, + projectId, + projectRef, + deployment, + options, + dashboardUrl, + authAccessToken, + compilationPath, + buildEnvVars, + branch, + isLocalBuild, +}: { + apiClient: CliApiClient; + projectId: string; + projectRef: string; + deployment: Deployment; + options: DeployCommandOptions; + dashboardUrl: string; + authAccessToken: string; + compilationPath: string; + buildEnvVars: Record | undefined; + branch: string | undefined; + isLocalBuild: boolean; +}) { + const authenticateToTriggerRegistry = options.localBuild; + const skipServerSideRegistryPush = options.localBuild; + const version = deployment.version; const { rawDeploymentLink, rawTestLink } = buildDeploymentLinks({ - dashboardUrl: authorization.dashboardUrl, - projectRef: resolvedConfig.project, + dashboardUrl, + projectRef, env: options.env, shortCode: deployment.shortCode, }); @@ -648,15 +727,15 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { externalBuildId: deployment.externalBuildData?.buildId, externalBuildToken: deployment.externalBuildData?.buildToken, externalBuildProjectId: deployment.externalBuildData?.projectId, - projectId: projectClient.id, - projectRef: resolvedConfig.project, - apiUrl: projectClient.client.apiURL, - apiKey: projectClient.client.accessToken!, - apiClient: projectClient.client, + projectId, + projectRef, + apiUrl: apiClient.apiURL, + apiKey: apiClient.accessToken!, + apiClient, branchName: branch, - authAccessToken: authorization.auth.accessToken, - compilationPath: destination.path, - buildEnvVars: buildManifest.build.env, + authAccessToken, + compilationPath, + buildEnvVars, compression: options.compression, cacheCompression: options.cacheCompression, compressionLevel: options.compressionLevel, @@ -691,7 +770,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const buildFailed = !warnings.ok || !buildResult.ok; if (buildFailed && canShowLocalBuildHint) { - const providerStatus = await projectClient.client.getRemoteBuildProviderStatus(); + const providerStatus = await apiClient.getRemoteBuildProviderStatus(); if (providerStatus.success && providerStatus.data.status === "degraded") { prettyWarning(providerStatus.data.message + "\n"); @@ -700,7 +779,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!warnings.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: warnings.summary }, buildResult.logs, @@ -714,7 +793,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!buildResult.ok) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "BuildError", message: buildResult.error }, buildResult.logs, @@ -725,11 +804,11 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { throw new SkipLoggingError("Failed to build image"); } - const getDeploymentResponse = await projectClient.client.getDeployment(deployment.id); + const getDeploymentResponse = await apiClient.getDeployment(deployment.id); if (!getDeploymentResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", message: getDeploymentResponse.error }, buildResult.logs, @@ -747,7 +826,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { : undefined; await failDeploy( - projectClient.client, + apiClient, deployment, { name: "DeploymentError", @@ -772,7 +851,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } } - const finalizeResponse = await projectClient.client.finalizeDeployment( + const finalizeResponse = await apiClient.finalizeDeployment( deployment.id, { imageDigest: buildResult.digest, @@ -797,7 +876,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { if (!finalizeResponse.success) { await failDeploy( - projectClient.client, + apiClient, deployment, { name: "FinalizeError", message: finalizeResponse.error }, buildResult.logs, @@ -1199,6 +1278,7 @@ async function handleNativeBuildServerDeploy({ dashboardUrl, userId, gitMeta, + branch, }: { apiClient: CliApiClient; config: Awaited>; @@ -1206,23 +1286,153 @@ 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`); + // --local-bundle: install + bundling happen locally; the server only runs the container build. + let bundleManifest: BuildManifest | undefined; + let bundleOutputPath: string | undefined; + let bundleBuildEnvVars: Record | undefined; + + if (options.localBundle) { + 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; + + // Extensions can set undefined values at runtime despite the manifest type + bundleBuildEnvVars = Object.fromEntries( + Object.entries(buildManifest.build.env ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" + ) + ); + + 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 BEFORE init: init enqueues the build synchronously, so a post-init sync races a fast build + if (!options.skipSyncEnvVars) { + const childVars = buildManifest.deploy.sync?.env ?? {}; + const parentVars = buildManifest.deploy.sync?.parentEnv ?? {}; + 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, }); @@ -1237,6 +1447,20 @@ async function handleNativeBuildServerDeploy({ logger.debug("Artifact created", { artifactKey }); + // The bundle key prefix is the ack that the server understood the deployment_bundle + // type; an older server silently stores the upload as a plain source context. + if (options.localBundle && !artifactKey.startsWith("bundles/")) { + $deploymentSpinner.stop("Failed creating deployment artifact"); + log.error( + 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)); @@ -1288,10 +1512,11 @@ async function handleNativeBuildServerDeploy({ : undefined; const initializeDeploymentResult = await apiClient.initializeDeployment({ - contentHash: "-", + 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, @@ -1300,6 +1525,11 @@ async function handleNativeBuildServerDeploy({ triggeredVia: getTriggeredVia(), externalId: options.externalId, force: options.force, + fromBundle: options.localBundle ? true : undefined, + buildEnvVars: + options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0 + ? bundleBuildEnvVars + : undefined, }); if (!initializeDeploymentResult.success) { @@ -1337,6 +1567,36 @@ async function handleNativeBuildServerDeploy({ return; } + // No ack for sent build env vars means an older server stripped them; fail fast. + // After the outcome=existing return: a reused deployment builds nothing. + if ( + options.localBundle && + bundleBuildEnvVars && + Object.keys(bundleBuildEnvVars).length > 0 && + !deployment.buildEnvVarsStored + ) { + // Best-effort cancel so the deployment does not linger until the queue timeout + const [cancelError] = await tryCatch( + apiClient.cancelDeployment(deployment.id, "Build environment variables were not stored") + ); + 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 exposedDeploymentLink = isLinksSupported ? cliLink(chalk.bold(rawDeploymentLink), rawDeploymentLink) : chalk.bold(rawDeploymentLink); @@ -1635,3 +1895,153 @@ export function verifyDirectory(dir: string, projectPath: string) { throw new Error(`Directory "${dir}" not found at ${projectPath}`); } } + +// 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) { + buildEnvVars = bundleManifest.build.env; + } + + 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 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 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..5e4b820aae5 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(), }); @@ -757,6 +758,8 @@ export const InitializeDeploymentResponseBody = z.object({ }), }) .optional(), + // Ack that buildEnvVars were stored; absence on an older server is a client-side hard error + buildEnvVarsStored: z.boolean().optional(), }); export type InitializeDeploymentResponseBody = z.infer; @@ -784,6 +787,8 @@ type NativeBuildOutput = BaseOutput & { artifactKey?: string; configFilePath?: string; skipEnqueue?: boolean; + fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -792,6 +797,8 @@ type NonNativeBuildOutput = BaseOutput & { artifactKey?: never; configFilePath?: never; skipEnqueue?: never; + fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -800,6 +807,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 +826,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 +940,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, });