feat(deploy): store local-bundle build env vars encrypted on the deployment

Replaces the trigger-build-args.json file and generated .dockerignore: the
bundle artifact is now secret-free. Build-arg values are sent with the init
request, stored aes-256-gcm encrypted in a new WorkerDeployment.buildEnvVars
column, and cleared on every terminal status transition.

- new dedicated GET /api/v1/deployments/:id/build-env-vars endpoint, used by
  the from-bundle build in attach mode; returns an empty record for terminal
  deployments and never 500s on a bad envelope
- size limits enforced server-side and pre-checked client-side (128 KiB
  serialized, 200 keys)
- version-skew guard: the CLI hard-errors when it sent vars and the server
  did not ack storing them
This commit is contained in:
Saadi Myftija
2026-07-22 18:09:40 +02:00
parent 08ce247f06
commit 1e226e1970
14 changed files with 302 additions and 57 deletions
@@ -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<string, string>;
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 });
}
}
@@ -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 }),
};
@@ -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,
},
});
@@ -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) => ({
@@ -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,
},
});
@@ -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,
},
});
@@ -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<ReturnType<typeof encryptSecret>> | 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,
};
});
}
@@ -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,
},
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN IF NOT EXISTS "buildEnvVars" JSONB;
@@ -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)
+15
View File
@@ -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 } };
+79 -47
View File
@@ -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<typeof DeployCommandOptions>;
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<string, string> | 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<string, string> | undefined;
const [buildArgsError, buildArgsRaw] = await tryCatch(
readFile(join(bundlePath, BUNDLE_BUILD_ARGS_FILE), "utf-8")
);
if (!buildArgsError) {
let parsed: { env?: Record<string, string> };
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<string, string> | 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."
);
@@ -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()
);
+27 -1
View File
@@ -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<typeof InitializeDeploymentResponseBody>;
@@ -786,6 +789,7 @@ type NativeBuildOutput = BaseOutput & {
configFilePath?: string;
skipEnqueue?: boolean;
fromBundle?: boolean;
buildEnvVars?: Record<string, string>;
};
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<typeof GetDeploymentResponseBody>;
// 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,
});