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 000000000..de8a62102 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.build-env-vars.ts @@ -0,0 +1,112 @@ +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type GetDeploymentBuildEnvVarsResponseBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { decryptSecret, EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server"; +import { FINAL_DEPLOYMENT_STATUSES } from "~/v3/services/failDeployment.server"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +// Returns the decrypted build-time env vars stored on a fromBundle deployment. +// Deliberately separate from the main GET deployment endpoint: this is secret +// material, and a dedicated route keeps access explicit and auditable. The vars +// are cleared when the deployment reaches a terminal status, so this only ever +// serves the active build window. +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + try { + // Next authenticate the request + const authenticationResult = await authenticateApiRequest(request); + + if (!authenticationResult) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const authenticatedEnv = authenticationResult.environment; + + const { deploymentId } = parsedParams.data; + + const deployment = await prisma.workerDeployment.findFirst({ + where: { + friendlyId: deploymentId, + environmentId: authenticatedEnv.id, + }, + select: { + id: true, + status: true, + buildEnvVars: true, + }, + }); + + if (!deployment) { + return json({ error: "Deployment not found" }, { status: 404 }); + } + + logger.info("Build env vars read", { + deploymentId, + environmentId: authenticatedEnv.id, + projectId: authenticatedEnv.projectId, + status: deployment.status, + hasVars: deployment.buildEnvVars !== null, + }); + + // Terminal deployments have their vars cleared; even if a clear is still in + // flight, never serve secrets for a build that is no longer active. + if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) { + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + if (!deployment.buildEnvVars) { + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars); + + if (!envelope.success) { + logger.error("Stored build env vars are not a valid encrypted envelope", { + deploymentId, + environmentId: authenticatedEnv.id, + }); + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + let variables: Record; + + try { + const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data); + variables = z.record(z.string()).parse(JSON.parse(decrypted)); + } catch (error) { + logger.error("Failed to decrypt stored build env vars", { + deploymentId, + environmentId: authenticatedEnv.id, + error, + }); + return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, { + status: 200, + }); + } + + return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to load deployment build env vars", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 5be291bae..5ef38acd5 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -60,6 +60,8 @@ export async function action({ request, params }: ActionFunctionArgs) { .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], eventStream: result.eventStream, canceledDeployments: result.canceledDeployments, + // Only ack when we actually stored vars; older CLIs ignore this field. + ...(result.buildEnvVarsStored ? { buildEnvVarsStored: true } : {}), } : { isPromoted: result.isPromoted }), }; diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 305ee45ce..4cdd36d08 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,9 +1,10 @@ import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; import { logger, tryCatch } from "@trigger.dev/core/v3"; -import type { - BackgroundWorker, - PrismaClientOrTransaction, - WorkerDeployment, +import { + Prisma, + type BackgroundWorker, + type PrismaClientOrTransaction, + type WorkerDeployment, } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { type TaskMetadataCache } from "~/services/taskMetadataCache.server"; @@ -313,6 +314,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { name: error.name, message: error.message, }, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index d4903ba53..3bf900b10 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -1,7 +1,7 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService } from "./baseService.server"; import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; -import { type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database"; +import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database"; import { BuildServerMetadata, logger, @@ -227,6 +227,8 @@ export class DeploymentService extends BaseService { status: "CANCELED", canceledAt: new Date(), canceledReason: data?.canceledReason, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }), (error) => ({ diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index 87b7618d7..7b2221c5c 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 0595cee1e..62576ed67 100644 --- a/apps/webapp/app/v3/services/finalizeDeployment.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeployment.server.ts @@ -1,4 +1,5 @@ import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; +import { Prisma } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; @@ -76,6 +77,8 @@ export class FinalizeDeploymentService extends BaseService { deployedAt: new Date(), // Only add the digest, if any imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 7097351d1..37d256a75 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -6,6 +6,7 @@ import { import { customAlphabet } from "nanoid"; import { env } from "~/env.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { encryptSecret } from "~/services/secrets/secretStore.server"; import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; @@ -29,6 +30,11 @@ import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); +// Limits for fromBundle build env vars — they expand into --build-arg values, so +// keep them well under exec argv limits while staying generous for env vars. +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; + type DeploymentEventStream = { s2: { basin: string; @@ -44,6 +50,7 @@ export type InitializeDeploymentResult = imageRef: string; eventStream?: DeploymentEventStream; canceledDeployments?: SupersededDeployment[]; + buildEnvVarsStored?: boolean; } | { outcome: "existing"; @@ -103,6 +110,7 @@ export class InitializeDeploymentService extends BaseService { outcome: "created", deployment: existingDeployment, imageRef: existingDeployment.imageReference ?? "", + buildEnvVarsStored: false, }; } @@ -268,6 +276,36 @@ export class InitializeDeploymentService extends BaseService { } : undefined; + // Encrypt fromBundle build env vars for storage on the deployment row. Only + // meaningful for pre-bundled deploys; cleared on every terminal transition. + let encryptedBuildEnvVars: Awaited> | undefined; + + if ( + payload.isNativeBuild && + payload.fromBundle && + payload.buildEnvVars && + Object.keys(payload.buildEnvVars).length > 0 + ) { + const buildEnvVars = payload.buildEnvVars; + + const keyCount = Object.keys(buildEnvVars).length; + if (keyCount > BUILD_ENV_VARS_MAX_KEYS) { + throw new ServiceValidationError( + `Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).` + ); + } + + const serialized = JSON.stringify(buildEnvVars); + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) { + throw new ServiceValidationError( + `Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.` + ); + } + + encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized); + } + const buildServerMetadata: BuildServerMetadata | undefined = payload.isNativeBuild || payload.buildId ? { @@ -344,6 +382,7 @@ export class InitializeDeploymentService extends BaseService { projectId: environment.projectId, externalBuildData, buildServerMetadata, + buildEnvVars: encryptedBuildEnvVars, triggeredById: triggeredBy?.id, type: payload.type, imageReference: imageRef, @@ -411,6 +450,7 @@ export class InitializeDeploymentService extends BaseService { imageRef: deployment.imageReference ?? "", eventStream, canceledDeployments, + buildEnvVarsStored: encryptedBuildEnvVars !== undefined, }; }); } diff --git a/apps/webapp/app/v3/services/timeoutDeployment.server.ts b/apps/webapp/app/v3/services/timeoutDeployment.server.ts index fa3de698e..573d1458b 100644 --- a/apps/webapp/app/v3/services/timeoutDeployment.server.ts +++ b/apps/webapp/app/v3/services/timeoutDeployment.server.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@trigger.dev/database"; import { logger } from "~/services/logger.server"; import { BaseService } from "./baseService.server"; import { commonWorker } from "../commonWorker.server"; @@ -45,6 +46,8 @@ export class TimeoutDeploymentService extends BaseService { status: "TIMED_OUT", failedAt: new Date(), errorData: { message: errorMessage, name: "TimeoutError" }, + // Build env vars only live for the active build window + buildEnvVars: Prisma.DbNull, }, }); diff --git a/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql b/internal-packages/database/prisma/migrations/20260722155558_add_worker_deployment_build_env_vars/migration.sql new file mode 100644 index 000000000..49e62e6e2 --- /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 470f2c251..81417db75 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2271,6 +2271,10 @@ model WorkerDeployment { externalBuildData Json? buildServerMetadata Json? + /// Encrypted build-time env vars for pre-bundled (fromBundle) deploys — an + /// EncryptedSecretValue envelope of a JSON record. Cleared when the deployment + /// reaches a terminal status; only ever exists for the active build window. + buildEnvVars Json? status WorkerDeploymentStatus @default(PENDING) type WorkerDeploymentType @default(V1) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index fba2e52e1..8b9fd56eb 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -23,6 +23,7 @@ import { DevDisconnectResponseBody, EnvironmentVariableResponseBody, FailDeploymentResponseBody, + GetDeploymentBuildEnvVarsResponseBody, GetDeploymentResponseBody, GetEnvironmentVariablesResponseBody, GetLatestDeploymentResponseBody, @@ -689,6 +690,20 @@ export class CliApiClient { ); } + async getDeploymentBuildEnvVars(deploymentId: string) { + if (!this.accessToken) { + throw new Error("getDeploymentBuildEnvVars: No access token"); + } + + return wrapZodFetch( + GetDeploymentBuildEnvVarsResponseBody, + `${this.apiURL}/api/v1/deployments/${deploymentId}/build-env-vars`, + { + headers: this.getHeaders(), + } + ); + } + async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) { if (!this.accessToken) { return { success: true as const, data: { notification: null } }; diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 51a7b683f..d2f03c203 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -26,7 +26,7 @@ import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; import { createBundleArchive } from "../deploy/bundleArchive.js"; import { S2 } from "@s2-dev/streamstore"; -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, unlink } from "node:fs/promises"; import { CommonCommandOptions, commonOptions, @@ -55,7 +55,7 @@ import { prettyWarning, } from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; -import { isDirectory, writeJSONFile } from "../utilities/fileSystem.js"; +import { isDirectory } from "../utilities/fileSystem.js"; import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; import { createGitMeta, isGitHubActions } from "../utilities/gitMeta.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; @@ -105,12 +105,12 @@ type DeployCommandOptions = z.infer; type Deployment = InitializeDeploymentResponseBody; -// Carries the build-arg VALUES for the `ARG` lines in the generated Containerfile. -// They only exist in the in-memory build manifest (build.json is deliberately scrubbed -// because it gets COPY'd into the image), so --local-bundle writes them to this file -// and --from-bundle reads them back. A .dockerignore entry keeps the file out of the -// image COPY context so the values never land in image layers. -const BUNDLE_BUILD_ARGS_FILE = "trigger-build-args.json"; +// Limits for the build-arg VALUES sent with --local-bundle deploys (they only exist in +// the in-memory build manifest — build.json is deliberately scrubbed because it gets +// COPY'd into the image). The server enforces the same limits authoritatively; this +// pre-check just fails fast with a friendly error before uploading anything. +const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024; +const BUILD_ENV_VARS_MAX_KEYS = 200; export function configureDeployCommand(program: Command) { return ( @@ -1305,6 +1305,9 @@ async function handleNativeBuildServerDeploy({ // server then runs just the container build from it. let bundleManifest: BuildManifest | undefined; let bundleOutputPath: string | undefined; + // Build-arg values for --local-bundle, sent with the init request and stored + // encrypted on the deployment (they're scrubbed from build.json). + let bundleBuildEnvVars: Record | undefined; if (options.localBundle) { // The container build runs on the build server with its own fixed settings — @@ -1367,23 +1370,25 @@ async function handleNativeBuildServerDeploy({ bundleManifest = buildManifest; bundleOutputPath = destination.path; - // Persist the build-arg values (scrubbed from build.json) for the build server's - // --from-bundle step, and keep them out of the image via .dockerignore. - await writeJSONFile(join(destination.path, BUNDLE_BUILD_ARGS_FILE), { - env: buildManifest.build.env ?? {}, - }); + // The build-arg values (scrubbed from build.json) travel via the deployment + // record (sent with the init request, stored encrypted server-side) — never + // as a file in the bundle. Pre-check the limits the server enforces. + bundleBuildEnvVars = buildManifest.build.env ?? {}; - // Append to a .dockerignore a build extension may have produced, never clobber it. - // Our exclusions always go LAST so a pre-existing negation (!file) can't re-include - // the build-args file into the image context. - const dockerignorePath = join(destination.path, ".dockerignore"); - const [, existingDockerignore] = await tryCatch(readFile(dockerignorePath, "utf-8")); - await writeFile( - dockerignorePath, - `${ - existingDockerignore ? existingDockerignore.trimEnd() + "\n" : "" - }${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n` - ); + const buildEnvVarCount = Object.keys(bundleBuildEnvVars).length; + const buildEnvVarBytes = Buffer.byteLength(JSON.stringify(bundleBuildEnvVars), "utf8"); + + if (buildEnvVarCount > BUILD_ENV_VARS_MAX_KEYS) { + throw new Error( + `Your build uses too many build environment variables: ${buildEnvVarCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).` + ); + } + + if (buildEnvVarBytes > BUILD_ENV_VARS_MAX_BYTES) { + throw new Error( + `Your build environment variables are too large: ${buildEnvVarBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.` + ); + } if (options.dryRun) { logger.info(`Dry run complete. View the built bundle at ${destination.path}`); @@ -1522,6 +1527,10 @@ async function handleNativeBuildServerDeploy({ externalId: options.externalId, force: options.force, fromBundle: options.localBundle ? true : undefined, + buildEnvVars: + options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0 + ? bundleBuildEnvVars + : undefined, }); if (!initializeDeploymentResult.success) { @@ -1532,6 +1541,26 @@ async function handleNativeBuildServerDeploy({ const deployment = initializeDeploymentResult.data; + // Version-skew guard: an older server silently strips unknown fields, so if we sent + // build env vars and the server didn't ack storing them, the remote build would run + // without them and fail in a confusing way. Fail fast instead. + if ( + options.localBundle && + bundleBuildEnvVars && + Object.keys(bundleBuildEnvVars).length > 0 && + !deployment.buildEnvVarsStored + ) { + $deploymentSpinner.stop("Failed to initialize deployment"); + log.error( + chalk.bold( + chalkError( + "This server does not support --local-bundle deploys with build environment variables yet. Deploy without --local-bundle instead." + ) + ) + ); + throw new OutroCommandError(`Deployment failed`); + } + const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`; const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${ options.env === "prod" ? "prod" : "stg" @@ -1862,8 +1891,8 @@ export function verifyDirectory(dir: string, projectPath: string) { // the bundling step, as produced by --local-bundle / a dry-run build). Used primarily // by the build server to run ONLY the container build for pre-bundled artifacts, but // also works standalone for local testing. Skips config loading entirely — the bundle -// has no trigger.config.ts source; everything needed comes from the bundle's build.json, -// the build-args file, and the deployment record. +// has no trigger.config.ts source; everything needed comes from the bundle's build.json +// and the deployment record (including the build-arg values, stored encrypted there). async function handleFromBundleDeploy({ bundleDir, options, @@ -1910,26 +1939,6 @@ async function handleFromBundleDeploy({ const bundleManifest = manifestResult.data; - // Recover the build-arg values scrubbed from build.json (written by --local-bundle). - // Optional: bundles without build-time env vars may not carry the file. - let buildEnvVars: Record | undefined; - const [buildArgsError, buildArgsRaw] = await tryCatch( - readFile(join(bundlePath, BUNDLE_BUILD_ARGS_FILE), "utf-8") - ); - - if (!buildArgsError) { - let parsed: { env?: Record }; - try { - parsed = JSON.parse(buildArgsRaw); - } catch { - throw new Error(`Invalid ${BUNDLE_BUILD_ARGS_FILE} in the bundle directory`); - } - buildEnvVars = (typeof parsed === "object" && parsed !== null ? parsed.env : undefined) ?? {}; - } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { - // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. - buildEnvVars = bundleManifest.build.env; - } - const projectRef = projectRefOverride ?? bundleManifest.config.project; const branch = options.env === "preview" ? getBranch({ specified: options.branch }) : undefined; @@ -1965,11 +1974,34 @@ async function handleFromBundleDeploy({ throw new Error("Failed to get project client"); } + // Recover the build-arg values scrubbed from build.json. In attach mode they were + // stored encrypted on the deployment by --local-bundle's init request; fetch them + // through the dedicated endpoint. An empty record is normal for builds that use no + // build-time env vars. + let buildEnvVars: Record | undefined; + + if (existingDeploymentId) { + const buildEnvVarsResult = + await projectClient.client.getDeploymentBuildEnvVars(existingDeploymentId); + + if (!buildEnvVarsResult.success) { + throw new Error( + `Failed to fetch the build environment variables for deployment ${existingDeploymentId}: ${buildEnvVarsResult.error}` + ); + } + + buildEnvVars = buildEnvVarsResult.data.variables; + } else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) { + // The scrubbed manifest can't carry values, but if a manifest somehow has them, use them. + buildEnvVars = bundleManifest.build.env; + } + if (!existingDeploymentId) { // The supported flow is attach mode (the build server sets // TRIGGER_EXISTING_DEPLOYMENT_ID). Fresh-init from a bundle is equivalent to a // plain local build and mainly useful for local testing — warn so nobody relies - // on it against cloud by accident. + // on it against cloud by accident. There are no stored build env vars on this + // path; the build proceeds without them. logger.warn( "No existing deployment to attach to — initializing a fresh local-build deployment from the bundle. This path is intended for testing." ); diff --git a/packages/cli-v3/src/deploy/bundleArchive.test.ts b/packages/cli-v3/src/deploy/bundleArchive.test.ts index ca63c0501..c35a5875d 100644 --- a/packages/cli-v3/src/deploy/bundleArchive.test.ts +++ b/packages/cli-v3/src/deploy/bundleArchive.test.ts @@ -25,8 +25,8 @@ describe("createBundleArchive", () => { await writeFile(join(bundleDir, "Containerfile"), "FROM scratch"); await writeFile(join(bundleDir, "package.json"), "{}"); await writeFile(join(bundleDir, "index.mjs"), "export {}"); - await writeFile(join(bundleDir, ".dockerignore"), "trigger-build-args.json\n"); - await writeFile(join(bundleDir, "trigger-build-args.json"), JSON.stringify({ env: {} })); + // A build extension may produce a .dockerignore — it must survive archiving + await writeFile(join(bundleDir, ".dockerignore"), "*.log\n"); await mkdir(join(bundleDir, ".trigger", "skills", "my-skill"), { recursive: true }); await writeFile(join(bundleDir, ".trigger", "skills", "my-skill", "SKILL.md"), "# skill"); @@ -48,7 +48,6 @@ describe("createBundleArchive", () => { "build.json", "index.mjs", "package.json", - "trigger-build-args.json", ].sort() ); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index a5b4b58bb..7e256e41f 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -758,6 +758,9 @@ export const InitializeDeploymentResponseBody = z.object({ }), }) .optional(), + // Ack that the server accepted and stored buildEnvVars from the request. The CLI + // treats its absence (older server) as a hard error when it sent non-empty vars. + buildEnvVarsStored: z.boolean().optional(), }); export type InitializeDeploymentResponseBody = z.infer; @@ -786,6 +789,7 @@ type NativeBuildOutput = BaseOutput & { configFilePath?: string; skipEnqueue?: boolean; fromBundle?: boolean; + buildEnvVars?: Record; }; type NonNativeBuildOutput = BaseOutput & { @@ -795,6 +799,7 @@ type NonNativeBuildOutput = BaseOutput & { configFilePath?: never; skipEnqueue?: never; fromBundle?: never; + buildEnvVars?: never; }; const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ @@ -806,6 +811,9 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase. // The uploaded artifact is a pre-built bundle (local install + bundle already done); // the build server should skip install/bundle and only run the container build. fromBundle: z.boolean().optional(), + // Build-time env var values for fromBundle deploys. Stored encrypted on the + // deployment and cleared once the deployment reaches a terminal status. + buildEnvVars: z.record(z.string()).optional(), }).superRefine((data, ctx) => { if (data.force && !data.externalId) { ctx.addIssue({ @@ -821,7 +829,15 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu if (data.isNativeBuild) { return { ...data, isNativeBuild: true as const }; } - const { skipPromotion, artifactKey, configFilePath, skipEnqueue, fromBundle, ...rest } = data; + const { + skipPromotion, + artifactKey, + configFilePath, + skipEnqueue, + fromBundle, + buildEnvVars, + ...rest + } = data; return { ...rest, isNativeBuild: false as const }; } ); @@ -927,6 +943,16 @@ export const GetDeploymentResponseBody = z.object({ export type GetDeploymentResponseBody = z.infer; +// Response of the dedicated build-env-vars endpoint (secret material — deliberately +// kept off GetDeploymentResponseBody). Empty record when none were stored. +export const GetDeploymentBuildEnvVarsResponseBody = z.object({ + variables: z.record(z.string()), +}); + +export type GetDeploymentBuildEnvVarsResponseBody = z.infer< + typeof GetDeploymentBuildEnvVarsResponseBody +>; + export const GetLatestDeploymentResponseBody = GetDeploymentResponseBody.omit({ worker: true, });