From 0be0511fc169eadaf23483240280889853134b93 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:17:20 +0200 Subject: [PATCH 01/15] feat(webapp): deploy settings endpoint resolving the build path from feature flags Adds GET /api/v1/projects/:projectRef/:env/deploy-settings, authenticated with the environment API key, returning the build path the CLI should use (depot, native, native_local_bundle) and where it came from. The path is resolved from four new enum feature flags, settable globally and per organization through the existing admin UIs: deployBuildPath plus deployBuildPathPreview/Staging/Production. Precedence: native unavailable on this install, project opt-out (disableNativeBuildServer), org env-type flag, org flag, global env-type flag, global flag, then depot. --- ...ojects.$projectRef.$env.deploy-settings.ts | 78 +++++++++++++ .../app/services/deploymentApiPaths.server.ts | 1 + apps/webapp/app/v3/deployBuildPath.ts | 76 ++++++++++++ apps/webapp/app/v3/featureFlags.ts | 11 ++ apps/webapp/test/deployBuildPath.test.ts | 109 ++++++++++++++++++ packages/core/src/v3/schemas/api.ts | 25 ++++ 6 files changed, 300 insertions(+) create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts create mode 100644 apps/webapp/app/v3/deployBuildPath.ts create mode 100644 apps/webapp/test/deployBuildPath.test.ts diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts new file mode 100644 index 00000000000..baa2d86c32b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts @@ -0,0 +1,78 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type GetDeploySettingsResponseBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { isBillingConfigured } from "~/services/platform.v3.server"; +import { BuildSettingsSchema } from "~/v3/buildSettings"; +import { resolveDeployBuildPath } from "~/v3/deployBuildPath"; +import { flags } from "~/v3/featureFlags.server"; +import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), + env: z.enum(["dev", "staging", "prod", "preview"]), +}); + +const ENV_SLUG_FOR_TYPE = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +} as const; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + 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 environment = authResult.authentication.environment; + const { projectRef, env } = parsedParams.data; + + if ( + environment.project.externalRef !== projectRef || + ENV_SLUG_FOR_TYPE[environment.type] !== env + ) { + return json({ error: "API key does not belong to this project environment" }, { status: 403 }); + } + + try { + const project = await $replica.project.findFirst({ + where: { id: environment.project.id }, + select: { buildSettings: true }, + }); + + const build = resolveDeployBuildPath({ + environmentType: environment.type, + orgFeatureFlags: environment.organization.featureFlags, + globalFlags: globalFlagsRegistry.current() ?? (await flags()), + projectBuildSettings: BuildSettingsSchema.safeParse(project?.buildSettings).data, + nativeBuildServerAvailable: isBillingConfigured(), + }); + + logger.debug("Resolved deploy settings", { + environmentId: environment.id, + projectRef, + build, + }); + + const body: GetDeploySettingsResponseBody = { build }; + return json(body); + } catch (error) { + logger.error("Failed to resolve deploy settings", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/deploymentApiPaths.server.ts b/apps/webapp/app/services/deploymentApiPaths.server.ts index c044394b244..fcd69a2c265 100644 --- a/apps/webapp/app/services/deploymentApiPaths.server.ts +++ b/apps/webapp/app/services/deploymentApiPaths.server.ts @@ -4,6 +4,7 @@ export const deploymentApiPaths: (RegExp | string)[] = [ // /current is runtime SDK surface, kept out of the deploy budget /^\/api\/v\d+\/deployments(?!\/current$)(\/|$)/, /^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)$/, + /^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)\/deploy-settings$/, /^\/api\/v1\/projects\/[^/]+\/envvars$/, /^\/api\/v1\/projects\/[^/]+\/envvars\/[^/]+\/import$/, /^\/api\/v1\/projects\/[^/]+\/branches$/, diff --git a/apps/webapp/app/v3/deployBuildPath.ts b/apps/webapp/app/v3/deployBuildPath.ts new file mode 100644 index 00000000000..71fec20953f --- /dev/null +++ b/apps/webapp/app/v3/deployBuildPath.ts @@ -0,0 +1,76 @@ +import type { + DeployBuildPath, + DeployBuildPathSource, + GetDeploySettingsResponseBody, + RuntimeEnvironmentType, +} from "@trigger.dev/core/v3"; +import { FEATURE_FLAG, FeatureFlagCatalog, type FeatureFlagKey } from "./featureFlags"; +import type { BuildSettings } from "./buildSettings"; + +const ENV_TYPE_FLAG: Partial> = { + PREVIEW: FEATURE_FLAG.deployBuildPathPreview, + STAGING: FEATURE_FLAG.deployBuildPathStaging, + PRODUCTION: FEATURE_FLAG.deployBuildPathProduction, +}; + +export type ResolveDeployBuildPathInput = { + environmentType: RuntimeEnvironmentType; + orgFeatureFlags: unknown; + globalFlags: Record | undefined; + projectBuildSettings: BuildSettings | null | undefined; + nativeBuildServerAvailable: boolean; +}; + +/** + * Precedence, first hit wins: native unavailable on this install → project opt-out → + * org[env type] → org → global[env type] → global → depot. Values that fail the catalog + * schema are skipped rather than treated as depot, matching `flag()`. + */ +export function resolveDeployBuildPath( + input: ResolveDeployBuildPathInput +): GetDeploySettingsResponseBody["build"] { + if (!input.nativeBuildServerAvailable) { + return { path: "depot", source: "unavailable" }; + } + + if (input.projectBuildSettings?.disableNativeBuildServer === true) { + return { path: "depot", source: "project_opt_out" }; + } + + const envKey = ENV_TYPE_FLAG[input.environmentType]; + const org = asRecord(input.orgFeatureFlags); + const global = input.globalFlags ?? {}; + + const candidates: Array< + [Record, FeatureFlagKey | undefined, DeployBuildPathSource] + > = [ + [org, envKey, "organization_environment"], + [org, FEATURE_FLAG.deployBuildPath, "organization"], + [global, envKey, "global_environment"], + [global, FEATURE_FLAG.deployBuildPath, "global"], + ]; + + for (const [flags, key, source] of candidates) { + if (!key) continue; + const path = readBuildPath(flags, key); + if (path) return { path, source }; + } + + return { path: "depot", source: "default" }; +} + +function readBuildPath( + flags: Record, + key: FeatureFlagKey +): DeployBuildPath | undefined { + const value = flags[key]; + if (value === undefined || value === null) return undefined; + const parsed = FeatureFlagCatalog[key].safeParse(value); + return parsed.success ? (parsed.data as DeployBuildPath) : undefined; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 3a88beb54bc..a4c97db2d0b 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { DeployBuildPath } from "@trigger.dev/core/v3"; export const FEATURE_FLAG = { defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId", @@ -37,6 +38,12 @@ export const FEATURE_FLAG = { // Fleet-wide pin for the complete cutover. Beats every per-org and per-env pin. runOpsMintShardOverride: "runOpsMintShardOverride", queueMetricsUiEnabled: "queueMetricsUiEnabled", + // Build path for CLI deploys (see deployBuildPath.ts). The env-type keys beat the plain + // key, org values beat global ones, and unset everywhere means depot. + deployBuildPath: "deployBuildPath", + deployBuildPathPreview: "deployBuildPathPreview", + deployBuildPathStaging: "deployBuildPathStaging", + deployBuildPathProduction: "deployBuildPathProduction", // Per-organization rollout for creating additional environment API keys. additionalApiKeysEnabled: "additionalApiKeysEnabled", // System-wide kill switch for issuing additional environment API keys. @@ -148,6 +155,10 @@ export const FeatureFlagCatalog = { // Per-org access to the Queue Metrics dashboard UI (view only; emission is global and // separate). Off unless enabled for the org. [FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(), + [FEATURE_FLAG.deployBuildPath]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathPreview]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathStaging]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathProduction]: DeployBuildPath, // Strict booleans prevent a stringified "false" from silently enabling API-key // creation or lookup. Cold/absent values resolve to the safe `false`. [FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(), diff --git a/apps/webapp/test/deployBuildPath.test.ts b/apps/webapp/test/deployBuildPath.test.ts new file mode 100644 index 00000000000..35519a02462 --- /dev/null +++ b/apps/webapp/test/deployBuildPath.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { resolveDeployBuildPath, type ResolveDeployBuildPathInput } from "~/v3/deployBuildPath"; + +const base: ResolveDeployBuildPathInput = { + environmentType: "PRODUCTION", + orgFeatureFlags: {}, + globalFlags: {}, + projectBuildSettings: undefined, + nativeBuildServerAvailable: true, +}; + +describe("resolveDeployBuildPath", () => { + it("defaults to depot when nothing is set", () => { + expect(resolveDeployBuildPath(base)).toEqual({ path: "depot", source: "default" }); + }); + + it("uses the global flag for every environment type", () => { + expect(resolveDeployBuildPath({ ...base, globalFlags: { deployBuildPath: "native" } })).toEqual( + { path: "native", source: "global" } + ); + }); + + it("prefers the global env-type flag over the plain global flag", () => { + const globalFlags = { deployBuildPath: "depot", deployBuildPathPreview: "native" }; + expect(resolveDeployBuildPath({ ...base, environmentType: "PREVIEW", globalFlags })).toEqual({ + path: "native", + source: "global_environment", + }); + expect(resolveDeployBuildPath({ ...base, environmentType: "STAGING", globalFlags })).toEqual({ + path: "depot", + source: "global", + }); + }); + + it("lets an org override beat the global flags, in both directions", () => { + expect( + resolveDeployBuildPath({ + ...base, + globalFlags: { deployBuildPathProduction: "native" }, + orgFeatureFlags: { deployBuildPath: "depot" }, + }) + ).toEqual({ path: "depot", source: "organization" }); + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPath: "native_local_bundle" }, + }) + ).toEqual({ path: "native_local_bundle", source: "organization" }); + }); + + it("prefers the org env-type flag over the org plain flag", () => { + expect( + resolveDeployBuildPath({ + ...base, + environmentType: "STAGING", + orgFeatureFlags: { deployBuildPath: "native", deployBuildPathStaging: "depot" }, + }) + ).toEqual({ path: "depot", source: "organization_environment" }); + }); + + it("ignores env-type flags for development environments", () => { + expect( + resolveDeployBuildPath({ + ...base, + environmentType: "DEVELOPMENT", + orgFeatureFlags: { deployBuildPathProduction: "native" }, + globalFlags: { deployBuildPath: "native" }, + }) + ).toEqual({ path: "native", source: "global" }); + }); + + it("skips values the catalog rejects instead of treating them as depot", () => { + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPath: "nope" }, + globalFlags: { deployBuildPath: "native" }, + }) + ).toEqual({ path: "native", source: "global" }); + }); + + it("tolerates a non-object org flag blob", () => { + expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: "garbage" })).toEqual({ + path: "depot", + source: "default", + }); + }); + + it("honors the project opt-out over every flag", () => { + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPath: "native" }, + globalFlags: { deployBuildPath: "native" }, + projectBuildSettings: { disableNativeBuildServer: true }, + }) + ).toEqual({ path: "depot", source: "project_opt_out" }); + }); + + it("falls back to depot when the native build server is not available", () => { + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPath: "native" }, + nativeBuildServerAvailable: false, + }) + ).toEqual({ path: "depot", source: "unavailable" }); + }); +}); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index a90430953d4..2284dfeb776 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -839,6 +839,31 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu export type InitializeDeploymentRequestBody = z.infer; +export const DeployBuildPath = z.enum(["depot", "native", "native_local_bundle"]); + +export type DeployBuildPath = z.infer; + +export const DeployBuildPathSource = z.enum([ + "default", + "global", + "global_environment", + "organization", + "organization_environment", + "project_opt_out", + "unavailable", +]); + +export type DeployBuildPathSource = z.infer; + +export const GetDeploySettingsResponseBody = z.object({ + build: z.object({ + path: DeployBuildPath, + source: DeployBuildPathSource, + }), +}); + +export type GetDeploySettingsResponseBody = z.infer; + export const RemoteBuildProviderStatusResponseBody = z.object({ status: z.enum(["operational", "degraded", "unknown"]), message: z.string(), From 0344c58eafb3282e0c063eaaff4f651229b84103 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:17:20 +0200 Subject: [PATCH 02/15] feat(cli): pick the deploy build path from the server unless a flag says otherwise Before building or uploading anything, deploy fetches the deploy settings for the target environment and dispatches on the returned build path. Explicit flags skip the round-trip: --native-build-server, --local-bundle, --local-build, --detach, and the new --depot-build. If the request fails or times out (3s) the CLI falls back to Depot and says so. --- .changeset/deploy-build-path-settings.md | 6 ++ packages/cli-v3/src/apiClient.ts | 13 ++++ packages/cli-v3/src/commands/deploy.ts | 91 +++++++++++++++++++++++- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 .changeset/deploy-build-path-settings.md diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md new file mode 100644 index 00000000000..4482e19c978 --- /dev/null +++ b/.changeset/deploy-build-path-settings.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: `--native-build-server`, `--local-bundle`, `--local-build`, and the new `--depot-build` skip the server decision entirely. diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index a7a07cf40eb..14b65317265 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -29,6 +29,7 @@ import { GetLatestDeploymentResponseBody, GetPersonalAccessTokenResponseSchema, GetProjectEnvResponse, + GetDeploySettingsResponseBody, GetProjectResponseBody, GetProjectRuntimesResponseBody, GetProjectsResponseBody, @@ -486,6 +487,18 @@ export class CliApiClient { ); } + async getDeploySettings(projectRef: string, env: string, signal?: AbortSignal) { + return wrapZodFetch( + GetDeploySettingsResponseBody, + `${this.apiURL}/api/v1/projects/${projectRef}/${env}/deploy-settings`, + { + method: "GET", + headers: this.getHeaders(), + signal, + } + ); + } + async getRemoteBuildProviderStatus() { return wrapZodFetch( RemoteBuildProviderStatusResponseBody, diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 74052486c13..0e6829aeb3b 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -6,6 +6,8 @@ import { tryCatch, } from "@trigger.dev/core/v3"; import type { + DeployBuildPath, + DeployBuildPathSource, InitializeDeploymentRequestBody, InitializeDeploymentResponseBody, GitMeta, @@ -91,6 +93,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ push: z.boolean().optional(), builder: z.string().default("trigger"), nativeBuildServer: z.boolean().default(false), + depotBuild: z.boolean().default(false), localBundle: z.boolean().default(false), fromBundle: z.string().optional(), detach: z.boolean().default(false), @@ -251,6 +254,18 @@ export function configureDeployCommand(program: Command) { "Use the native build server for building the image" ) ) + .addOption( + new CommandOption( + "--depot-build", + "Build the image with Depot, ignoring the build path configured for this project on the server" + ).conflicts([ + "nativeBuildServer", + "localBundle", + "localBuild", + "forceLocalBuild", + "fromBundle", + ]) + ) .addOption( new CommandOption( "--local-bundle", @@ -445,7 +460,9 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } - if (options.localBundle) { + const buildPath = await resolveBuildPath(projectClient.client, resolvedConfig.project, options); + + if (buildPath === "native_local_bundle") { await handleLocalBundleDeploy({ apiClient: projectClient.client, config: resolvedConfig, @@ -458,7 +475,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { return; } - if (options.nativeBuildServer) { + if (buildPath === "native") { await handleNativeBuildServerDeploy({ apiClient: projectClient.client, config: resolvedConfig, @@ -1237,6 +1254,76 @@ function getTriggeredVia(): DeploymentTriggeredVia { return "cli:manual"; } +const DEPLOY_SETTINGS_TIMEOUT_MS = 3_000; + +const BUILD_PATH_LABEL: Record = { + depot: "Depot", + native: "native build server", + native_local_bundle: "native build server (local bundle)", +}; + +const BUILD_PATH_SOURCE_LABEL: Record = { + default: "server default", + global: "configured on the server", + global_environment: "configured on the server for this environment type", + organization: "configured for your organization", + organization_environment: "configured for your organization's environments of this type", + project_opt_out: "the native build server is disabled in the project settings", + unavailable: "the native build server is not available on this server", +}; + +/** + * Explicit path flags always win and skip the server round-trip. Otherwise the server + * decides (org/env-type feature flags); any failure to ask falls open to Depot, the path + * older CLIs use unconditionally. + */ +async function resolveBuildPath( + apiClient: CliApiClient, + projectRef: string, + options: DeployCommandOptions +): Promise { + if (options.localBundle) { + logger.debug("Build path from --local-bundle"); + return "native_local_bundle"; + } + + if (options.nativeBuildServer) { + logger.debug(`Build path from ${options.detach ? "--detach" : "--native-build-server"}`); + return "native"; + } + + if (options.depotBuild || options.localBuild) { + logger.debug(`Build path from ${options.localBuild ? "--local-build" : "--depot-build"}`); + return "depot"; + } + + const [error, result] = await tryCatch( + apiClient.getDeploySettings( + projectRef, + options.env, + AbortSignal.timeout(DEPLOY_SETTINGS_TIMEOUT_MS) + ) + ); + + if (error || !result.success) { + logger.debug("Failed to fetch deploy settings", { + error: error ?? (result && !result.success ? result.error : undefined), + }); + log.warn("Could not fetch the deploy settings from the server, using the Depot build path"); + return "depot"; + } + + const { path, source } = result.data.build; + + if (path !== "depot") { + log.info(`Using the ${BUILD_PATH_LABEL[path]} build path (${BUILD_PATH_SOURCE_LABEL[source]})`); + } else { + logger.debug(`Build path depot (${source})`); + } + + return path; +} + async function handleNativeBuildServerDeploy({ apiClient, options, From 89d766506caba6aedbe831c725c8cca23dd665d0 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:31:10 +0200 Subject: [PATCH 03/15] fix(cli): single-attempt deploy settings fetch, quiet 404, keep dry runs off the native path The deploy settings request now makes one attempt so the 3s timeout is the real upper bound instead of ~10s of retries against an aborted signal. A 404 (server without the endpoint) falls back to Depot silently since that is the behaviour that server expects. When the server picks the native build server but --dry-run is set, the CLI stays on Depot, because the native path has no dry-run mode and would deploy for real. --- .changeset/deploy-build-path-settings.md | 2 +- packages/cli-v3/src/apiClient.ts | 3 ++- packages/cli-v3/src/commands/deploy.ts | 18 +++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index 4482e19c978..69eb4cfd1fe 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: `--native-build-server`, `--local-bundle`, `--local-build`, and the new `--depot-build` skip the server decision entirely. +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: `--native-build-server`, `--local-bundle`, `--local-build`, `--detach`, and the new `--depot-build` skip the server decision entirely. diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 14b65317265..f888d4680e3 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -495,7 +495,8 @@ export class CliApiClient { method: "GET", headers: this.getHeaders(), signal, - } + }, + { retry: { maxAttempts: 1 } } ); } diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 0e6829aeb3b..4a090427b64 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1306,15 +1306,27 @@ async function resolveBuildPath( ); if (error || !result.success) { - logger.debug("Failed to fetch deploy settings", { - error: error ?? (result && !result.success ? result.error : undefined), - }); + const failure = error ?? (result && !result.success ? result : undefined); + logger.debug("Failed to fetch deploy settings", { failure }); + + // A 404 is an older server without the endpoint; depot is exactly what it expects. + if (!error && result && !result.success && result.statusCode === 404) { + return "depot"; + } + log.warn("Could not fetch the deploy settings from the server, using the Depot build path"); return "depot"; } const { path, source } = result.data.build; + if (path === "native" && options.dryRun) { + log.info( + "Dry run is not supported on the native build server path, using the Depot build path" + ); + return "depot"; + } + if (path !== "depot") { log.info(`Using the ${BUILD_PATH_LABEL[path]} build path (${BUILD_PATH_SOURCE_LABEL[source]})`); } else { From e2f83094590b273fe61fb2ac04ae5499d115f137 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:38:10 +0200 Subject: [PATCH 04/15] feat(cli): add --native-build, decouple --detach from the build path --native-build is the visible way to force the native build server; --native-build-server stays as a hidden alias. --detach no longer implies native: it only works on the native paths, so a deploy that resolves to Depot with --detach set fails with a pointer to --native-build. --- .changeset/deploy-build-path-settings.md | 2 +- packages/cli-v3/src/commands/deploy.ts | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index 69eb4cfd1fe..79e6e72a225 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: `--native-build-server`, `--local-bundle`, `--local-build`, `--detach`, and the new `--depot-build` skip the server decision entirely. +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-bundle`, `--local-build`, and the new `--depot-build` skip the server decision entirely. `--detach` no longer selects a build path; it requires the native build server. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 4a090427b64..912d8f56c96 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -93,6 +93,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ push: z.boolean().optional(), builder: z.string().default("trigger"), nativeBuildServer: z.boolean().default(false), + nativeBuild: z.boolean().default(false), depotBuild: z.boolean().default(false), localBundle: z.boolean().default(false), fromBundle: z.string().optional(), @@ -250,10 +251,13 @@ export function configureDeployCommand(program: Command) { ) .addOption( new CommandOption( - "--native-build-server", - "Use the native build server for building the image" + "--native-build", + "Build the image on the native build server, ignoring the build path configured for this project on the server" ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild", "depotBuild", "fromBundle"]) ) + .addOption(new CommandOption("--native-build-server", "Alias for --native-build").hideHelp()) .addOption( new CommandOption( "--depot-build", @@ -286,8 +290,8 @@ export function configureDeployCommand(program: Command) { .addOption( new CommandOption( "--detach", - "Return immediately after the deployment is queued, do not wait for the build to complete. Implies using the native build server." - ).implies({ nativeBuildServer: true }) + "Return immediately after the deployment is queued, do not wait for the build to complete. Only available with the native build server." + ) ) .addOption(new CommandOption("--plain", "Plain output").hideHelp()) .action(async (path, options) => { @@ -461,6 +465,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } const buildPath = await resolveBuildPath(projectClient.client, resolvedConfig.project, options); + assertDetachSupported(buildPath, options); if (buildPath === "native_local_bundle") { await handleLocalBundleDeploy({ @@ -1256,6 +1261,14 @@ function getTriggeredVia(): DeploymentTriggeredVia { const DEPLOY_SETTINGS_TIMEOUT_MS = 3_000; +function assertDetachSupported(buildPath: DeployBuildPath, options: DeployCommandOptions) { + if (options.detach && buildPath === "depot") { + throw new Error( + "--detach is only available with the native build server. Pass --native-build, or configure the native build path for this environment." + ); + } +} + const BUILD_PATH_LABEL: Record = { depot: "Depot", native: "native build server", @@ -1288,7 +1301,7 @@ async function resolveBuildPath( } if (options.nativeBuildServer) { - logger.debug(`Build path from ${options.detach ? "--detach" : "--native-build-server"}`); + logger.debug("Build path from --native-build"); return "native"; } From a157b3297d820b703f2b88481edc2c83014e0a3b Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:45:27 +0200 Subject: [PATCH 05/15] feat(cli): --local-bundle modifies the native path instead of selecting it Like --detach, --local-bundle now only applies when the build path is the native build server, whether chosen by --native-build or by the server, and errors on Depot. The server can still select native_local_bundle directly via the feature flags. --- .changeset/deploy-build-path-settings.md | 2 +- packages/cli-v3/src/commands/deploy.ts | 44 +++++++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index 79e6e72a225..98fc519d47d 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-bundle`, `--local-build`, and the new `--depot-build` skip the server decision entirely. `--detach` no longer selects a build path; it requires the native build server. +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they apply on top of the native build server and error on Depot. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 912d8f56c96..41ea9e0ab43 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -273,10 +273,8 @@ export function configureDeployCommand(program: Command) { .addOption( new CommandOption( "--local-bundle", - "Experimental: install and bundle locally, upload only the build output, and build the image remotely. Implies using the native build server." - ) - .implies({ nativeBuildServer: true }) - .conflicts(["localBuild", "forceLocalBuild"]) + "Experimental: install and bundle locally and upload only the build output. Only available with the native build server." + ).conflicts(["localBuild", "forceLocalBuild", "depotBuild"]) ) .addOption( new CommandOption( @@ -464,8 +462,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } - const buildPath = await resolveBuildPath(projectClient.client, resolvedConfig.project, options); - assertDetachSupported(buildPath, options); + const buildPath = applyNativeOnlyOptions( + await resolveBuildPath(projectClient.client, resolvedConfig.project, options), + options + ); if (buildPath === "native_local_bundle") { await handleLocalBundleDeploy({ @@ -1261,12 +1261,27 @@ function getTriggeredVia(): DeploymentTriggeredVia { const DEPLOY_SETTINGS_TIMEOUT_MS = 3_000; -function assertDetachSupported(buildPath: DeployBuildPath, options: DeployCommandOptions) { - if (options.detach && buildPath === "depot") { +/** + * --local-bundle and --detach modify the native path rather than select it: with the + * native build server they apply, on Depot they are an error. + */ +function applyNativeOnlyOptions( + buildPath: DeployBuildPath, + options: DeployCommandOptions +): DeployBuildPath { + const nativeOnly = options.localBundle + ? "--local-bundle" + : options.detach + ? "--detach" + : undefined; + + if (nativeOnly && buildPath === "depot") { throw new Error( - "--detach is only available with the native build server. Pass --native-build, or configure the native build path for this environment." + `${nativeOnly} is only available with the native build server. Pass --native-build, or configure the native build path for this environment.` ); } + + return options.localBundle ? "native_local_bundle" : buildPath; } const BUILD_PATH_LABEL: Record = { @@ -1286,20 +1301,15 @@ const BUILD_PATH_SOURCE_LABEL: Record = { }; /** - * Explicit path flags always win and skip the server round-trip. Otherwise the server - * decides (org/env-type feature flags); any failure to ask falls open to Depot, the path - * older CLIs use unconditionally. + * Explicit path flags (--native-build, --depot-build, --local-build) win and skip the server + * round-trip. Otherwise the server decides (org/env-type feature flags); any failure to ask + * falls open to Depot, the path older CLIs use unconditionally. */ async function resolveBuildPath( apiClient: CliApiClient, projectRef: string, options: DeployCommandOptions ): Promise { - if (options.localBundle) { - logger.debug("Build path from --local-bundle"); - return "native_local_bundle"; - } - if (options.nativeBuildServer) { logger.debug("Build path from --native-build"); return "native"; From b190c54d9dd6e7da655e68ca5b90ce1d8c7b215a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 13:53:35 +0200 Subject: [PATCH 06/15] fix(cli): apply build path modifiers in one place, keep every native dry run local --local-bundle, --detach and --dry-run are applied after the build path is resolved, whether a flag or the server chose it. A native dry run now always bundles locally on the Depot path (it previously deployed for real with --native-build --dry-run), and --local-bundle --dry-run stays on the local bundle path, which supports dry runs. --- packages/cli-v3/src/commands/deploy.ts | 41 ++++------------- packages/cli-v3/src/deploy/buildPath.test.ts | 47 ++++++++++++++++++++ packages/cli-v3/src/deploy/buildPath.ts | 40 +++++++++++++++++ 3 files changed, 96 insertions(+), 32 deletions(-) create mode 100644 packages/cli-v3/src/deploy/buildPath.test.ts create mode 100644 packages/cli-v3/src/deploy/buildPath.ts diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 41ea9e0ab43..0f32efd625c 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -27,6 +27,7 @@ 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 { applyBuildPathOptions } from "../deploy/buildPath.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -462,10 +463,16 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } - const buildPath = applyNativeOnlyOptions( - await resolveBuildPath(projectClient.client, resolvedConfig.project, options), + const resolvedBuildPath = await resolveBuildPath( + projectClient.client, + resolvedConfig.project, options ); + const buildPath = applyBuildPathOptions(resolvedBuildPath, options); + + if (options.dryRun && resolvedBuildPath === "native" && buildPath === "depot") { + log.info("Dry run is not supported on the native build server path, bundling locally instead"); + } if (buildPath === "native_local_bundle") { await handleLocalBundleDeploy({ @@ -1261,29 +1268,6 @@ function getTriggeredVia(): DeploymentTriggeredVia { const DEPLOY_SETTINGS_TIMEOUT_MS = 3_000; -/** - * --local-bundle and --detach modify the native path rather than select it: with the - * native build server they apply, on Depot they are an error. - */ -function applyNativeOnlyOptions( - buildPath: DeployBuildPath, - options: DeployCommandOptions -): DeployBuildPath { - const nativeOnly = options.localBundle - ? "--local-bundle" - : options.detach - ? "--detach" - : undefined; - - if (nativeOnly && buildPath === "depot") { - throw new Error( - `${nativeOnly} is only available with the native build server. Pass --native-build, or configure the native build path for this environment.` - ); - } - - return options.localBundle ? "native_local_bundle" : buildPath; -} - const BUILD_PATH_LABEL: Record = { depot: "Depot", native: "native build server", @@ -1343,13 +1327,6 @@ async function resolveBuildPath( const { path, source } = result.data.build; - if (path === "native" && options.dryRun) { - log.info( - "Dry run is not supported on the native build server path, using the Depot build path" - ); - return "depot"; - } - if (path !== "depot") { log.info(`Using the ${BUILD_PATH_LABEL[path]} build path (${BUILD_PATH_SOURCE_LABEL[source]})`); } else { diff --git a/packages/cli-v3/src/deploy/buildPath.test.ts b/packages/cli-v3/src/deploy/buildPath.test.ts new file mode 100644 index 00000000000..6ad1833b3be --- /dev/null +++ b/packages/cli-v3/src/deploy/buildPath.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { applyBuildPathOptions } from "./buildPath.js"; + +const none = { localBundle: false, detach: false, dryRun: false }; + +describe("applyBuildPathOptions", () => { + it("passes the resolved path through without modifiers", () => { + expect(applyBuildPathOptions("depot", none)).toBe("depot"); + expect(applyBuildPathOptions("native", none)).toBe("native"); + expect(applyBuildPathOptions("native_local_bundle", none)).toBe("native_local_bundle"); + }); + + it("upgrades a native path to the local bundle variant with --local-bundle", () => { + expect(applyBuildPathOptions("native", { ...none, localBundle: true })).toBe( + "native_local_bundle" + ); + }); + + it("rejects --local-bundle and --detach on Depot", () => { + expect(() => applyBuildPathOptions("depot", { ...none, localBundle: true })).toThrow( + /--local-bundle is only available with the native build server/ + ); + expect(() => applyBuildPathOptions("depot", { ...none, detach: true })).toThrow( + /--detach is only available with the native build server/ + ); + }); + + it("keeps --detach on the native paths", () => { + expect(applyBuildPathOptions("native", { ...none, detach: true })).toBe("native"); + expect(applyBuildPathOptions("native_local_bundle", { ...none, detach: true })).toBe( + "native_local_bundle" + ); + }); + + it("moves a native dry run onto the Depot path, however native was chosen", () => { + expect(applyBuildPathOptions("native", { ...none, dryRun: true })).toBe("depot"); + }); + + it("lets a local-bundle dry run stay on the local bundle path", () => { + expect(applyBuildPathOptions("native", { ...none, localBundle: true, dryRun: true })).toBe( + "native_local_bundle" + ); + expect(applyBuildPathOptions("native_local_bundle", { ...none, dryRun: true })).toBe( + "native_local_bundle" + ); + }); +}); diff --git a/packages/cli-v3/src/deploy/buildPath.ts b/packages/cli-v3/src/deploy/buildPath.ts new file mode 100644 index 00000000000..8cd060aefd4 --- /dev/null +++ b/packages/cli-v3/src/deploy/buildPath.ts @@ -0,0 +1,40 @@ +import type { DeployBuildPath } from "@trigger.dev/core/v3/schemas"; + +export type BuildPathOptions = { + localBundle: boolean; + detach: boolean; + dryRun: boolean; +}; + +/** + * Applies the native-only modifiers to the resolved build path. --local-bundle and --detach + * need the native build server: with it they apply, on Depot they throw. A dry run never + * reaches the plain native path (it has no dry-run mode and would deploy for real); it is + * bundled locally on the Depot path instead. The local-bundle path handles dry runs itself. + */ +export function applyBuildPathOptions( + resolved: DeployBuildPath, + options: BuildPathOptions +): DeployBuildPath { + const nativeOnly = options.localBundle + ? "--local-bundle" + : options.detach + ? "--detach" + : undefined; + + if (nativeOnly && resolved === "depot") { + throw new Error( + `${nativeOnly} is only available with the native build server. Pass --native-build, or configure the native build path for this environment.` + ); + } + + if (options.localBundle) { + return "native_local_bundle"; + } + + if (options.dryRun && resolved === "native") { + return "depot"; + } + + return resolved; +} From f092a9b8428cca74b26578c1c260ad7c3ef1dd0a Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 14:42:52 +0200 Subject: [PATCH 07/15] fix(cli): clearer flag conflicts and more headroom on the deploy settings fetch Conflicts now name --native-build instead of the hidden alias, and --depot-build conflicts with --detach up front. The deploy settings timeout is 5s (still a single attempt): the endpoint answers in ~350ms but TCP connect stalls of over a second were observed, and a spurious timeout would silently move a deploy to Depot. --- packages/cli-v3/src/commands/deploy.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 0f32efd625c..b4cd9af4f13 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -226,13 +226,14 @@ export function configureDeployCommand(program: Command) { .implies({ localBuild: true, }) - .conflicts("nativeBuildServer") + .conflicts(["nativeBuild", "nativeBuildServer"]) .hideHelp() ) .addOption( - new CommandOption("--local-build", "Build the deployment image locally").conflicts( - "nativeBuildServer" - ) + new CommandOption("--local-build", "Build the deployment image locally").conflicts([ + "nativeBuild", + "nativeBuildServer", + ]) ) .addOption(new CommandOption("--push", "Push the image after local builds").hideHelp()) .addOption( @@ -264,11 +265,13 @@ export function configureDeployCommand(program: Command) { "--depot-build", "Build the image with Depot, ignoring the build path configured for this project on the server" ).conflicts([ + "nativeBuild", "nativeBuildServer", "localBundle", "localBuild", "forceLocalBuild", "fromBundle", + "detach", ]) ) .addOption( @@ -1266,7 +1269,7 @@ function getTriggeredVia(): DeploymentTriggeredVia { return "cli:manual"; } -const DEPLOY_SETTINGS_TIMEOUT_MS = 3_000; +const DEPLOY_SETTINGS_TIMEOUT_MS = 5_000; const BUILD_PATH_LABEL: Record = { depot: "Depot", From a5c28669706c4ba0a0da1cf652f749e662d0a2b6 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 15:24:33 +0200 Subject: [PATCH 08/15] fix(cli): keep native-only flags on native when the deploy settings fetch fails With --detach or --local-bundle the intent is unambiguous, so a failed or missing deploy-settings response now falls back to the native build server instead of Depot, where those flags would have errored. The response's source field is accepted as any string so newer servers can add values without breaking older CLIs. --local-build and --from-bundle reject --detach/--local-bundle up front, the chosen path is announced as "Building on the native build server", and the new flags are in the CLI reference docs. --- .changeset/deploy-build-path-settings.md | 2 +- docs/snippets/cli-commands-deploy.mdx | 8 ++++++ packages/cli-v3/src/commands/deploy.ts | 35 ++++++++++++++++-------- packages/core/src/v3/schemas/api.ts | 3 +- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index 98fc519d47d..9849466d2e1 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they apply on top of the native build server and error on Depot. +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they apply on top of the native build server and error on Depot. A dry run never runs on the native build server anymore (it used to deploy for real with `--native-build-server --dry-run`); it bundles locally instead. diff --git a/docs/snippets/cli-commands-deploy.mdx b/docs/snippets/cli-commands-deploy.mdx index ce8125836aa..f776a5ee793 100644 --- a/docs/snippets/cli-commands-deploy.mdx +++ b/docs/snippets/cli-commands-deploy.mdx @@ -102,6 +102,14 @@ npx trigger.dev@latest deploy [path] Force building the deployment image locally using your local Docker. This is automatic when self-hosting. + + Build the image on the native build server, ignoring the build path configured for the project on the server. + + + + Build the image with Depot, ignoring the build path configured for the project on the server. + + ### Common options These options are available on most commands. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index b4cd9af4f13..43efca88b40 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -226,13 +226,15 @@ export function configureDeployCommand(program: Command) { .implies({ localBuild: true, }) - .conflicts(["nativeBuild", "nativeBuildServer"]) + .conflicts(["nativeBuild", "nativeBuildServer", "localBundle", "detach"]) .hideHelp() ) .addOption( new CommandOption("--local-build", "Build the deployment image locally").conflicts([ "nativeBuild", "nativeBuildServer", + "localBundle", + "detach", ]) ) .addOption(new CommandOption("--push", "Push the image after local builds").hideHelp()) @@ -286,7 +288,7 @@ export function configureDeployCommand(program: Command) { "Internal: build the image from a pre-built bundle directory. Implies a local build." ) .implies({ localBuild: true }) - .conflicts(["nativeBuildServer", "localBundle"]) + .conflicts(["nativeBuild", "nativeBuildServer", "depotBuild", "localBundle", "detach"]) .hideHelp() ) .addOption( @@ -1272,9 +1274,9 @@ function getTriggeredVia(): DeploymentTriggeredVia { const DEPLOY_SETTINGS_TIMEOUT_MS = 5_000; const BUILD_PATH_LABEL: Record = { - depot: "Depot", - native: "native build server", - native_local_bundle: "native build server (local bundle)", + depot: "Building with Depot", + native: "Building on the native build server", + native_local_bundle: "Building on the native build server from a local bundle", }; const BUILD_PATH_SOURCE_LABEL: Record = { @@ -1319,19 +1321,30 @@ async function resolveBuildPath( const failure = error ?? (result && !result.success ? result : undefined); logger.debug("Failed to fetch deploy settings", { failure }); - // A 404 is an older server without the endpoint; depot is exactly what it expects. - if (!error && result && !result.success && result.statusCode === 404) { - return "depot"; + // --detach and --local-bundle only exist on the native path, so the user's intent is + // clear without the server; everyone else gets Depot, the path older CLIs always used. + const fallback: DeployBuildPath = options.detach || options.localBundle ? "native" : "depot"; + const is404 = !error && result && !result.success && result.statusCode === 404; + + // A 404 is an older server without the endpoint; nothing to warn about. + if (!is404) { + log.warn( + `Could not fetch the deploy settings from the server, ${ + fallback === "native" ? "using the native build server" : "using the Depot build path" + }` + ); } - log.warn("Could not fetch the deploy settings from the server, using the Depot build path"); - return "depot"; + return fallback; } const { path, source } = result.data.build; if (path !== "depot") { - log.info(`Using the ${BUILD_PATH_LABEL[path]} build path (${BUILD_PATH_SOURCE_LABEL[source]})`); + const sourceLabel = + BUILD_PATH_SOURCE_LABEL[source as DeployBuildPathSource] ?? + `configured on the server: ${source}`; + log.info(`${BUILD_PATH_LABEL[path]} (${sourceLabel})`); } else { logger.debug(`Build path depot (${source})`); } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 2284dfeb776..ce675429ce7 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -855,10 +855,11 @@ export const DeployBuildPathSource = z.enum([ export type DeployBuildPathSource = z.infer; +// `source` is informational, so the CLI accepts values newer servers may add. export const GetDeploySettingsResponseBody = z.object({ build: z.object({ path: DeployBuildPath, - source: DeployBuildPathSource, + source: z.string(), }), }); From 3fd027522dfc47cdc841dacc4176d1231cacea66 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 15:28:20 +0200 Subject: [PATCH 09/15] fix(webapp): read only the opt-out from build settings in deploy-settings An unrelated invalid key in Project.buildSettings made the whole parse fail and silently dropped disableNativeBuildServer. Adds a route test and more resolver cases (cold registry, non-object org blobs, env-type keys never leaking across environment types). --- ...ojects.$projectRef.$env.deploy-settings.ts | 5 +- apps/webapp/test/deployBuildPath.test.ts | 56 +++++++ apps/webapp/test/deploySettingsRoute.test.ts | 145 ++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/test/deploySettingsRoute.test.ts diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts index baa2d86c32b..11a0036d3f7 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts @@ -59,7 +59,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { environmentType: environment.type, orgFeatureFlags: environment.organization.featureFlags, globalFlags: globalFlagsRegistry.current() ?? (await flags()), - projectBuildSettings: BuildSettingsSchema.safeParse(project?.buildSettings).data, + // Only the opt-out matters here; an unrelated invalid key must not make it fail open. + projectBuildSettings: BuildSettingsSchema.pick({ disableNativeBuildServer: true }).safeParse( + project?.buildSettings + ).data, nativeBuildServerAvailable: isBillingConfigured(), }); diff --git a/apps/webapp/test/deployBuildPath.test.ts b/apps/webapp/test/deployBuildPath.test.ts index 35519a02462..c3d5968937f 100644 --- a/apps/webapp/test/deployBuildPath.test.ts +++ b/apps/webapp/test/deployBuildPath.test.ts @@ -97,6 +97,62 @@ describe("resolveDeployBuildPath", () => { ).toEqual({ path: "depot", source: "project_opt_out" }); }); + it("treats a cold registry, a null blob and an array blob as unset", () => { + expect(resolveDeployBuildPath({ ...base, globalFlags: undefined })).toEqual({ + path: "depot", + source: "default", + }); + expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: null })).toEqual({ + path: "depot", + source: "default", + }); + expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: ["native"] })).toEqual({ + path: "depot", + source: "default", + }); + }); + + it("applies the plain global flag to preview and staging environments", () => { + for (const environmentType of ["PREVIEW", "STAGING"] as const) { + expect( + resolveDeployBuildPath({ + ...base, + environmentType, + globalFlags: { deployBuildPath: "native" }, + }) + ).toEqual({ path: "native", source: "global" }); + } + }); + + it("never lets another environment type's key leak", () => { + expect( + resolveDeployBuildPath({ + ...base, + environmentType: "PRODUCTION", + orgFeatureFlags: { deployBuildPathStaging: "native" }, + }) + ).toEqual({ path: "depot", source: "default" }); + }); + + it("skips an invalid env-type value and still honors the org plain flag", () => { + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPathProduction: "nope", deployBuildPath: "native" }, + }) + ).toEqual({ path: "native", source: "organization" }); + }); + + it("does not treat an explicit disableNativeBuildServer: false as an opt-out", () => { + expect( + resolveDeployBuildPath({ + ...base, + orgFeatureFlags: { deployBuildPath: "native" }, + projectBuildSettings: { disableNativeBuildServer: false }, + }) + ).toEqual({ path: "native", source: "organization" }); + }); + it("falls back to depot when the native build server is not available", () => { expect( resolveDeployBuildPath({ diff --git a/apps/webapp/test/deploySettingsRoute.test.ts b/apps/webapp/test/deploySettingsRoute.test.ts new file mode 100644 index 00000000000..99368a11ac4 --- /dev/null +++ b/apps/webapp/test/deploySettingsRoute.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), + findFirst: vi.fn<(...args: any[]) => Promise>(), + isBillingConfigured: vi.fn<() => boolean>(), + current: vi.fn<() => Record | undefined>(), + flags: vi.fn<() => Promise>>(), +})); + +vi.mock("~/services/apiAuth.server", () => ({ + authenticateApiKeyWithScope: mocks.authenticateApiKeyWithScope, +})); +vi.mock("~/db.server", () => ({ $replica: { project: { findFirst: mocks.findFirst } } })); +vi.mock("~/services/platform.v3.server", () => ({ + isBillingConfigured: mocks.isBillingConfigured, +})); +vi.mock("~/v3/globalFlagsRegistry.server", () => ({ + globalFlagsRegistry: { current: mocks.current }, +})); +vi.mock("~/v3/featureFlags.server", () => ({ flags: mocks.flags })); +vi.mock("~/services/logger.server", () => ({ + logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +import { loader } from "~/routes/api.v1.projects.$projectRef.$env.deploy-settings"; + +function environment(overrides: Record = {}) { + return { + id: "env_1", + type: "PRODUCTION", + project: { id: "proj_1", externalRef: "proj_ref" }, + organization: { featureFlags: {} }, + ...overrides, + }; +} + +function load(env = "prod", projectRef = "proj_ref") { + return loader({ + request: new Request( + `https://app.example.com/api/v1/projects/${projectRef}/${env}/deploy-settings` + ), + params: { projectRef, env }, + context: {}, + }); +} + +describe("deploy settings route", () => { + beforeEach(() => { + mocks.authenticateApiKeyWithScope.mockReset(); + mocks.findFirst.mockReset(); + mocks.isBillingConfigured.mockReset(); + mocks.current.mockReset(); + mocks.flags.mockReset(); + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: true, + authentication: { environment: environment() }, + }); + mocks.findFirst.mockResolvedValue({ buildSettings: null }); + mocks.isBillingConfigured.mockReturnValue(true); + mocks.current.mockReturnValue({}); + mocks.flags.mockResolvedValue({}); + }); + + it("rejects an unknown env slug before authenticating", async () => { + const response = await load("nope"); + expect(response.status).toBe(400); + expect(mocks.authenticateApiKeyWithScope).not.toHaveBeenCalled(); + }); + + it("passes the auth failure through", async () => { + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: false, + status: 401, + error: "Invalid API key", + }); + const response = await load(); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Invalid API key" }); + }); + + it("refuses a key that belongs to another project or environment type", async () => { + expect((await load("prod", "proj_other")).status).toBe(403); + expect((await load("staging")).status).toBe(403); + }); + + it("accepts a preview branch environment on the preview slug", async () => { + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: true, + authentication: { environment: environment({ type: "PREVIEW" }) }, + }); + const response = await load("preview"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ build: { path: "depot", source: "default" } }); + }); + + it("resolves from the org flags and the registry snapshot without hitting flags()", async () => { + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: true, + authentication: { + environment: environment({ + organization: { featureFlags: { deployBuildPathProduction: "native" } }, + }), + }, + }); + mocks.current.mockReturnValue({ deployBuildPath: "depot" }); + const response = await load(); + expect(await response.json()).toEqual({ + build: { path: "native", source: "organization_environment" }, + }); + expect(mocks.flags).not.toHaveBeenCalled(); + }); + + it("falls back to flags() when the registry is cold", async () => { + mocks.current.mockReturnValue(undefined); + mocks.flags.mockResolvedValue({ deployBuildPath: "native" }); + expect(await (await load()).json()).toEqual({ build: { path: "native", source: "global" } }); + expect(mocks.flags).toHaveBeenCalledTimes(1); + }); + + it("honors the project opt-out even when other build settings are malformed", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + mocks.findFirst.mockResolvedValue({ + buildSettings: { installCommand: null, disableNativeBuildServer: true }, + }); + expect(await (await load()).json()).toEqual({ + build: { path: "depot", source: "project_opt_out" }, + }); + }); + + it("reports native as unavailable without billing", async () => { + mocks.isBillingConfigured.mockReturnValue(false); + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + expect(await (await load()).json()).toEqual({ + build: { path: "depot", source: "unavailable" }, + }); + }); + + it("returns 500 when the project lookup fails", async () => { + mocks.findFirst.mockRejectedValue(new Error("db down")); + const response = await load(); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Internal Server Error" }); + }); +}); From 039f939b447814630d040954b9b1c941b976aad3 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 15:31:34 +0200 Subject: [PATCH 10/15] feat(cli): --local-bundle and --detach require --native-build Both flags only make sense on the native build server path, so they now demand the explicit --native-build instead of depending on what the server would choose. The check runs before any network call, and the deploy settings fetch keeps a plain Depot fallback since these flags never reach it anymore. --- .changeset/deploy-build-path-settings.md | 2 +- packages/cli-v3/src/commands/deploy.ts | 28 +++++++------- packages/cli-v3/src/deploy/buildPath.test.ts | 39 ++++++++++++-------- packages/cli-v3/src/deploy/buildPath.ts | 31 ++++++++-------- 4 files changed, 53 insertions(+), 47 deletions(-) diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index 9849466d2e1..b830a7b8cf2 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they apply on top of the native build server and error on Depot. A dry run never runs on the native build server anymore (it used to deploy for real with `--native-build-server --dry-run`); it bundles locally instead. +`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they require `--native-build`. A dry run never runs on the native build server anymore (it used to deploy for real with `--native-build-server --dry-run`); it bundles locally instead. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 43efca88b40..06dcb7b5595 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -27,7 +27,7 @@ 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 { applyBuildPathOptions } from "../deploy/buildPath.js"; +import { applyBuildPathOptions, nativeOnlyFlagError } from "../deploy/buildPath.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -279,7 +279,7 @@ export function configureDeployCommand(program: Command) { .addOption( new CommandOption( "--local-bundle", - "Experimental: install and bundle locally and upload only the build output. Only available with the native build server." + "Experimental: install and bundle locally and upload only the build output. Requires --native-build." ).conflicts(["localBuild", "forceLocalBuild", "depotBuild"]) ) .addOption( @@ -294,7 +294,7 @@ export function configureDeployCommand(program: Command) { .addOption( new CommandOption( "--detach", - "Return immediately after the deployment is queued, do not wait for the build to complete. Only available with the native build server." + "Return immediately after the deployment is queued, do not wait for the build to complete. Requires --native-build." ) ) .addOption(new CommandOption("--plain", "Plain output").hideHelp()) @@ -314,6 +314,11 @@ async function deployCommand(dir: string, options: unknown) { } async function _deployCommand(dir: string, options: DeployCommandOptions) { + const nativeOnlyError = nativeOnlyFlagError(options); + if (nativeOnlyError) { + throw new Error(nativeOnlyError); + } + if (options.externalId !== undefined) { options.externalId = options.externalId.trim(); @@ -1291,7 +1296,8 @@ const BUILD_PATH_SOURCE_LABEL: Record = { /** * Explicit path flags (--native-build, --depot-build, --local-build) win and skip the server - * round-trip. Otherwise the server decides (org/env-type feature flags); any failure to ask + * round-trip; --local-bundle and --detach require --native-build, so they never depend on the + * server either. Otherwise the server decides (org/env-type feature flags); any failure to ask * falls open to Depot, the path older CLIs use unconditionally. */ async function resolveBuildPath( @@ -1321,21 +1327,13 @@ async function resolveBuildPath( const failure = error ?? (result && !result.success ? result : undefined); logger.debug("Failed to fetch deploy settings", { failure }); - // --detach and --local-bundle only exist on the native path, so the user's intent is - // clear without the server; everyone else gets Depot, the path older CLIs always used. - const fallback: DeployBuildPath = options.detach || options.localBundle ? "native" : "depot"; + // A 404 is an older server without the endpoint; Depot is exactly what it expects. const is404 = !error && result && !result.success && result.statusCode === 404; - - // A 404 is an older server without the endpoint; nothing to warn about. if (!is404) { - log.warn( - `Could not fetch the deploy settings from the server, ${ - fallback === "native" ? "using the native build server" : "using the Depot build path" - }` - ); + log.warn("Could not fetch the deploy settings from the server, using the Depot build path"); } - return fallback; + return "depot"; } const { path, source } = result.data.build; diff --git a/packages/cli-v3/src/deploy/buildPath.test.ts b/packages/cli-v3/src/deploy/buildPath.test.ts index 6ad1833b3be..0bd2e40bbab 100644 --- a/packages/cli-v3/src/deploy/buildPath.test.ts +++ b/packages/cli-v3/src/deploy/buildPath.test.ts @@ -1,7 +1,24 @@ import { describe, expect, it } from "vitest"; -import { applyBuildPathOptions } from "./buildPath.js"; +import { applyBuildPathOptions, nativeOnlyFlagError } from "./buildPath.js"; -const none = { localBundle: false, detach: false, dryRun: false }; +const none = { nativeBuildServer: false, localBundle: false, detach: false, dryRun: false }; +const native = { ...none, nativeBuildServer: true }; + +describe("nativeOnlyFlagError", () => { + it("requires --native-build for --local-bundle and --detach", () => { + expect(nativeOnlyFlagError({ ...none, localBundle: true })).toBe( + "--local-bundle requires --native-build." + ); + expect(nativeOnlyFlagError({ ...none, detach: true })).toBe( + "--detach requires --native-build." + ); + }); + + it("is satisfied by --native-build and by having neither flag", () => { + expect(nativeOnlyFlagError({ ...native, localBundle: true, detach: true })).toBeUndefined(); + expect(nativeOnlyFlagError(none)).toBeUndefined(); + }); +}); describe("applyBuildPathOptions", () => { it("passes the resolved path through without modifiers", () => { @@ -11,33 +28,25 @@ describe("applyBuildPathOptions", () => { }); it("upgrades a native path to the local bundle variant with --local-bundle", () => { - expect(applyBuildPathOptions("native", { ...none, localBundle: true })).toBe( + expect(applyBuildPathOptions("native", { ...native, localBundle: true })).toBe( "native_local_bundle" ); }); - it("rejects --local-bundle and --detach on Depot", () => { - expect(() => applyBuildPathOptions("depot", { ...none, localBundle: true })).toThrow( - /--local-bundle is only available with the native build server/ - ); - expect(() => applyBuildPathOptions("depot", { ...none, detach: true })).toThrow( - /--detach is only available with the native build server/ - ); - }); - it("keeps --detach on the native paths", () => { - expect(applyBuildPathOptions("native", { ...none, detach: true })).toBe("native"); - expect(applyBuildPathOptions("native_local_bundle", { ...none, detach: true })).toBe( + expect(applyBuildPathOptions("native", { ...native, detach: true })).toBe("native"); + expect(applyBuildPathOptions("native_local_bundle", { ...native, detach: true })).toBe( "native_local_bundle" ); }); it("moves a native dry run onto the Depot path, however native was chosen", () => { expect(applyBuildPathOptions("native", { ...none, dryRun: true })).toBe("depot"); + expect(applyBuildPathOptions("native", { ...native, dryRun: true })).toBe("depot"); }); it("lets a local-bundle dry run stay on the local bundle path", () => { - expect(applyBuildPathOptions("native", { ...none, localBundle: true, dryRun: true })).toBe( + expect(applyBuildPathOptions("native", { ...native, localBundle: true, dryRun: true })).toBe( "native_local_bundle" ); expect(applyBuildPathOptions("native_local_bundle", { ...none, dryRun: true })).toBe( diff --git a/packages/cli-v3/src/deploy/buildPath.ts b/packages/cli-v3/src/deploy/buildPath.ts index 8cd060aefd4..759ff066664 100644 --- a/packages/cli-v3/src/deploy/buildPath.ts +++ b/packages/cli-v3/src/deploy/buildPath.ts @@ -1,33 +1,32 @@ import type { DeployBuildPath } from "@trigger.dev/core/v3/schemas"; export type BuildPathOptions = { + nativeBuildServer: boolean; localBundle: boolean; detach: boolean; dryRun: boolean; }; /** - * Applies the native-only modifiers to the resolved build path. --local-bundle and --detach - * need the native build server: with it they apply, on Depot they throw. A dry run never - * reaches the plain native path (it has no dry-run mode and would deploy for real); it is - * bundled locally on the Depot path instead. The local-bundle path handles dry runs itself. + * --local-bundle and --detach only exist on the native build server path, so they must be + * paired with --native-build. Returns the error message for the first violation, if any. + */ +export function nativeOnlyFlagError(options: BuildPathOptions): string | undefined { + if (options.nativeBuildServer) return undefined; + const flag = options.localBundle ? "--local-bundle" : options.detach ? "--detach" : undefined; + return flag ? `${flag} requires --native-build.` : undefined; +} + +/** + * Applies the modifiers to the resolved build path. --local-bundle turns the native path + * into its local-bundle variant. A dry run never reaches the plain native path (it has no + * dry-run mode and would deploy for real); it is bundled locally on the Depot path instead. + * The local-bundle path handles dry runs itself. */ export function applyBuildPathOptions( resolved: DeployBuildPath, options: BuildPathOptions ): DeployBuildPath { - const nativeOnly = options.localBundle - ? "--local-bundle" - : options.detach - ? "--detach" - : undefined; - - if (nativeOnly && resolved === "depot") { - throw new Error( - `${nativeOnly} is only available with the native build server. Pass --native-build, or configure the native build path for this environment.` - ); - } - if (options.localBundle) { return "native_local_bundle"; } From 6a02bfc7c2b383bbd207a0411ee8dbd3d9723a25 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 16:13:56 +0200 Subject: [PATCH 11/15] refactor(deploy-settings): return only build_path, resolve it in DeploymentService The endpoint responds with `{ build_path }`; the resolution source is logged server-side instead of sent to the CLI. The resolver moves into DeploymentService.getDeploySettings as a neverthrow ResultAsync and no longer reads the project's disableNativeBuildServer setting, which only applies to the GitHub integration. Drops the docs snippet change, shortens the changeset and trims comments. --- .changeset/deploy-build-path-settings.md | 2 +- ...ojects.$projectRef.$env.deploy-settings.ts | 90 +++++----- apps/webapp/app/v3/deployBuildPath.ts | 76 -------- apps/webapp/app/v3/featureFlags.ts | 3 +- .../app/v3/services/deployment.server.ts | 70 +++++++- apps/webapp/test/deployBuildPath.test.ts | 165 ------------------ apps/webapp/test/deploySettingsRoute.test.ts | 93 +++------- .../deploymentServiceDeploySettings.test.ts | 147 ++++++++++++++++ docs/snippets/cli-commands-deploy.mdx | 8 - packages/cli-v3/src/commands/deploy.ts | 30 +--- packages/cli-v3/src/deploy/buildPath.ts | 11 +- packages/core/src/v3/schemas/api.ts | 18 +- 12 files changed, 295 insertions(+), 418 deletions(-) delete mode 100644 apps/webapp/app/v3/deployBuildPath.ts delete mode 100644 apps/webapp/test/deployBuildPath.test.ts create mode 100644 apps/webapp/test/deploymentServiceDeploySettings.test.ts diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md index b830a7b8cf2..e42f9b2e7c5 100644 --- a/.changeset/deploy-build-path-settings.md +++ b/.changeset/deploy-build-path-settings.md @@ -3,4 +3,4 @@ "@trigger.dev/core": patch --- -`trigger.dev deploy` now asks the server which build path to use before it builds or uploads anything, so the native build server can be enabled per organization and per environment type without a CLI change. Explicit flags still win: the new `--native-build` (`--native-build-server` stays as a hidden alias), `--local-build`, and the new `--depot-build` skip the server decision entirely. `--local-bundle` and `--detach` no longer select a build path; they require `--native-build`. A dry run never runs on the native build server anymore (it used to deploy for real with `--native-build-server --dry-run`); it bundles locally instead. +`trigger.dev deploy` now asks the server whether to build with Depot or the native build server unless `--native-build`, `--depot-build`, or `--local-build` is passed, so the native build server can be rolled out per organization without a CLI change. `--local-bundle` and `--detach` now require `--native-build`. diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts index 11a0036d3f7..bd7daf5e92d 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts @@ -1,14 +1,9 @@ import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { type GetDeploySettingsResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { $replica } from "~/db.server"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { isBillingConfigured } from "~/services/platform.v3.server"; -import { BuildSettingsSchema } from "~/v3/buildSettings"; -import { resolveDeployBuildPath } from "~/v3/deployBuildPath"; -import { flags } from "~/v3/featureFlags.server"; -import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -29,53 +24,56 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authResult = await authenticateApiKeyWithScope(request, { - action: "read", - resource: { type: "deployments" }, - }); + 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 }); - } + if (!authResult.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: authResult.error }, { status: authResult.status }); + } - const environment = authResult.authentication.environment; - const { projectRef, env } = parsedParams.data; + const { environment } = authResult.authentication; + const { projectRef, env } = parsedParams.data; - if ( - environment.project.externalRef !== projectRef || - ENV_SLUG_FOR_TYPE[environment.type] !== env - ) { - return json({ error: "API key does not belong to this project environment" }, { status: 403 }); - } + if ( + environment.project.externalRef !== projectRef || + ENV_SLUG_FOR_TYPE[environment.type] !== env + ) { + return json( + { error: "API key does not belong to this project environment" }, + { status: 403 } + ); + } - try { - const project = await $replica.project.findFirst({ - where: { id: environment.project.id }, - select: { buildSettings: true }, - }); - - const build = resolveDeployBuildPath({ - environmentType: environment.type, - orgFeatureFlags: environment.organization.featureFlags, - globalFlags: globalFlagsRegistry.current() ?? (await flags()), - // Only the opt-out matters here; an unrelated invalid key must not make it fail open. - projectBuildSettings: BuildSettingsSchema.pick({ disableNativeBuildServer: true }).safeParse( - project?.buildSettings - ).data, - nativeBuildServerAvailable: isBillingConfigured(), - }); + const deploymentService = new DeploymentService(); - logger.debug("Resolved deploy settings", { - environmentId: environment.id, - projectRef, - build, - }); + return await deploymentService.getDeploySettings(environment).match( + ({ buildPath, buildPathSource }) => { + logger.info("Resolved deploy build path", { + environmentId: environment.id, + projectRef, + env, + buildPath, + buildPathSource, + }); - const body: GetDeploySettingsResponseBody = { build }; - return json(body); + return json({ build_path: buildPath } satisfies GetDeploySettingsResponseBody); + }, + (error) => { + switch (error.type) { + case "other": + default: + error.type satisfies "other"; + logger.error("Failed to resolve deploy settings", { error: error.cause }); + return json({ error: "Internal server error" }, { status: 500 }); + } + } + ); } catch (error) { logger.error("Failed to resolve deploy settings", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); + return json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/webapp/app/v3/deployBuildPath.ts b/apps/webapp/app/v3/deployBuildPath.ts deleted file mode 100644 index 71fec20953f..00000000000 --- a/apps/webapp/app/v3/deployBuildPath.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { - DeployBuildPath, - DeployBuildPathSource, - GetDeploySettingsResponseBody, - RuntimeEnvironmentType, -} from "@trigger.dev/core/v3"; -import { FEATURE_FLAG, FeatureFlagCatalog, type FeatureFlagKey } from "./featureFlags"; -import type { BuildSettings } from "./buildSettings"; - -const ENV_TYPE_FLAG: Partial> = { - PREVIEW: FEATURE_FLAG.deployBuildPathPreview, - STAGING: FEATURE_FLAG.deployBuildPathStaging, - PRODUCTION: FEATURE_FLAG.deployBuildPathProduction, -}; - -export type ResolveDeployBuildPathInput = { - environmentType: RuntimeEnvironmentType; - orgFeatureFlags: unknown; - globalFlags: Record | undefined; - projectBuildSettings: BuildSettings | null | undefined; - nativeBuildServerAvailable: boolean; -}; - -/** - * Precedence, first hit wins: native unavailable on this install → project opt-out → - * org[env type] → org → global[env type] → global → depot. Values that fail the catalog - * schema are skipped rather than treated as depot, matching `flag()`. - */ -export function resolveDeployBuildPath( - input: ResolveDeployBuildPathInput -): GetDeploySettingsResponseBody["build"] { - if (!input.nativeBuildServerAvailable) { - return { path: "depot", source: "unavailable" }; - } - - if (input.projectBuildSettings?.disableNativeBuildServer === true) { - return { path: "depot", source: "project_opt_out" }; - } - - const envKey = ENV_TYPE_FLAG[input.environmentType]; - const org = asRecord(input.orgFeatureFlags); - const global = input.globalFlags ?? {}; - - const candidates: Array< - [Record, FeatureFlagKey | undefined, DeployBuildPathSource] - > = [ - [org, envKey, "organization_environment"], - [org, FEATURE_FLAG.deployBuildPath, "organization"], - [global, envKey, "global_environment"], - [global, FEATURE_FLAG.deployBuildPath, "global"], - ]; - - for (const [flags, key, source] of candidates) { - if (!key) continue; - const path = readBuildPath(flags, key); - if (path) return { path, source }; - } - - return { path: "depot", source: "default" }; -} - -function readBuildPath( - flags: Record, - key: FeatureFlagKey -): DeployBuildPath | undefined { - const value = flags[key]; - if (value === undefined || value === null) return undefined; - const parsed = FeatureFlagCatalog[key].safeParse(value); - return parsed.success ? (parsed.data as DeployBuildPath) : undefined; -} - -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index a4c97db2d0b..3dccd5c4cc7 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -38,8 +38,7 @@ export const FEATURE_FLAG = { // Fleet-wide pin for the complete cutover. Beats every per-org and per-env pin. runOpsMintShardOverride: "runOpsMintShardOverride", queueMetricsUiEnabled: "queueMetricsUiEnabled", - // Build path for CLI deploys (see deployBuildPath.ts). The env-type keys beat the plain - // key, org values beat global ones, and unset everywhere means depot. + // Build path for CLI deploys, resolved by DeploymentService.getDeploySettings. deployBuildPath: "deployBuildPath", deployBuildPathPreview: "deployBuildPathPreview", deployBuildPathStaging: "deployBuildPathStaging", diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 0eeaa82b3d6..9a4343d5d53 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -4,16 +4,25 @@ import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database"; import { BuildServerMetadata, + DeployBuildPath, logger, type GitMeta, type DeploymentEvent, + type RuntimeEnvironmentType, } from "@trigger.dev/core/v3"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { recordDeploymentFinished } from "./recordDeploymentFinished.server"; import { env } from "~/env.server"; import { createRemoteImageBuild } from "../remoteImageBuilder.server"; import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server"; -import { enqueueBuild, generateRegistryCredentials } from "~/services/platform.v3.server"; +import { + enqueueBuild, + generateRegistryCredentials, + isBillingConfigured, +} from "~/services/platform.v3.server"; +import { FEATURE_FLAG, type FeatureFlagKey } from "../featureFlags"; +import { flags } from "../featureFlags.server"; +import { globalFlagsRegistry } from "../globalFlagsRegistry.server"; import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore"; import { createRedisClient } from "~/redis.server"; @@ -28,6 +37,20 @@ const s2TokenRedis = createRedisClient("s2-token-cache", { }); const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined; +const DEPLOY_BUILD_PATH_ENV_FLAG: Partial> = { + PREVIEW: FEATURE_FLAG.deployBuildPathPreview, + STAGING: FEATURE_FLAG.deployBuildPathStaging, + PRODUCTION: FEATURE_FLAG.deployBuildPathProduction, +}; + +export type DeployBuildPathSource = + | "unavailable" + | "organization_environment" + | "organization" + | "global_environment" + | "global" + | "default"; + export class DeploymentService extends BaseService { /** * Progresses a deployment from PENDING to INSTALLING and then to BUILDING. @@ -282,6 +305,51 @@ export class DeploymentService extends BaseService { .map(() => undefined); } + public getDeploySettings( + authenticatedEnv: Pick + ): ResultAsync< + { buildPath: DeployBuildPath; buildPathSource: DeployBuildPathSource }, + { type: "other"; cause: unknown } + > { + if (!isBillingConfigured()) { + return okAsync({ buildPath: "depot" as const, buildPathSource: "unavailable" as const }); + } + + const loadGlobalFlags = () => + fromPromise(Promise.resolve(globalFlagsRegistry.current() ?? flags()), (error) => ({ + type: "other" as const, + cause: error, + })); + + const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type]; + const orgFlags = authenticatedEnv.organization.featureFlags; + const orgFlagSet: Record = + orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags) + ? (orgFlags as Record) + : {}; + + return loadGlobalFlags().map((globalFlagSet: Record) => { + const candidates: Array< + [Record, FeatureFlagKey | undefined, DeployBuildPathSource] + > = [ + [orgFlagSet, envKey, "organization_environment"], + [orgFlagSet, FEATURE_FLAG.deployBuildPath, "organization"], + [globalFlagSet, envKey, "global_environment"], + [globalFlagSet, FEATURE_FLAG.deployBuildPath, "global"], + ]; + + for (const [flagSet, key, buildPathSource] of candidates) { + if (!key) continue; + const parsed = DeployBuildPath.safeParse(flagSet[key]); + if (parsed.success) { + return { buildPath: parsed.data, buildPathSource }; + } + } + + return { buildPath: "depot" as const, buildPathSource: "default" as const }; + }); + } + /** * Generates registry credentials for a deployment. Returns an error if the deployment is in a final state. * diff --git a/apps/webapp/test/deployBuildPath.test.ts b/apps/webapp/test/deployBuildPath.test.ts deleted file mode 100644 index c3d5968937f..00000000000 --- a/apps/webapp/test/deployBuildPath.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveDeployBuildPath, type ResolveDeployBuildPathInput } from "~/v3/deployBuildPath"; - -const base: ResolveDeployBuildPathInput = { - environmentType: "PRODUCTION", - orgFeatureFlags: {}, - globalFlags: {}, - projectBuildSettings: undefined, - nativeBuildServerAvailable: true, -}; - -describe("resolveDeployBuildPath", () => { - it("defaults to depot when nothing is set", () => { - expect(resolveDeployBuildPath(base)).toEqual({ path: "depot", source: "default" }); - }); - - it("uses the global flag for every environment type", () => { - expect(resolveDeployBuildPath({ ...base, globalFlags: { deployBuildPath: "native" } })).toEqual( - { path: "native", source: "global" } - ); - }); - - it("prefers the global env-type flag over the plain global flag", () => { - const globalFlags = { deployBuildPath: "depot", deployBuildPathPreview: "native" }; - expect(resolveDeployBuildPath({ ...base, environmentType: "PREVIEW", globalFlags })).toEqual({ - path: "native", - source: "global_environment", - }); - expect(resolveDeployBuildPath({ ...base, environmentType: "STAGING", globalFlags })).toEqual({ - path: "depot", - source: "global", - }); - }); - - it("lets an org override beat the global flags, in both directions", () => { - expect( - resolveDeployBuildPath({ - ...base, - globalFlags: { deployBuildPathProduction: "native" }, - orgFeatureFlags: { deployBuildPath: "depot" }, - }) - ).toEqual({ path: "depot", source: "organization" }); - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPath: "native_local_bundle" }, - }) - ).toEqual({ path: "native_local_bundle", source: "organization" }); - }); - - it("prefers the org env-type flag over the org plain flag", () => { - expect( - resolveDeployBuildPath({ - ...base, - environmentType: "STAGING", - orgFeatureFlags: { deployBuildPath: "native", deployBuildPathStaging: "depot" }, - }) - ).toEqual({ path: "depot", source: "organization_environment" }); - }); - - it("ignores env-type flags for development environments", () => { - expect( - resolveDeployBuildPath({ - ...base, - environmentType: "DEVELOPMENT", - orgFeatureFlags: { deployBuildPathProduction: "native" }, - globalFlags: { deployBuildPath: "native" }, - }) - ).toEqual({ path: "native", source: "global" }); - }); - - it("skips values the catalog rejects instead of treating them as depot", () => { - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPath: "nope" }, - globalFlags: { deployBuildPath: "native" }, - }) - ).toEqual({ path: "native", source: "global" }); - }); - - it("tolerates a non-object org flag blob", () => { - expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: "garbage" })).toEqual({ - path: "depot", - source: "default", - }); - }); - - it("honors the project opt-out over every flag", () => { - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPath: "native" }, - globalFlags: { deployBuildPath: "native" }, - projectBuildSettings: { disableNativeBuildServer: true }, - }) - ).toEqual({ path: "depot", source: "project_opt_out" }); - }); - - it("treats a cold registry, a null blob and an array blob as unset", () => { - expect(resolveDeployBuildPath({ ...base, globalFlags: undefined })).toEqual({ - path: "depot", - source: "default", - }); - expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: null })).toEqual({ - path: "depot", - source: "default", - }); - expect(resolveDeployBuildPath({ ...base, orgFeatureFlags: ["native"] })).toEqual({ - path: "depot", - source: "default", - }); - }); - - it("applies the plain global flag to preview and staging environments", () => { - for (const environmentType of ["PREVIEW", "STAGING"] as const) { - expect( - resolveDeployBuildPath({ - ...base, - environmentType, - globalFlags: { deployBuildPath: "native" }, - }) - ).toEqual({ path: "native", source: "global" }); - } - }); - - it("never lets another environment type's key leak", () => { - expect( - resolveDeployBuildPath({ - ...base, - environmentType: "PRODUCTION", - orgFeatureFlags: { deployBuildPathStaging: "native" }, - }) - ).toEqual({ path: "depot", source: "default" }); - }); - - it("skips an invalid env-type value and still honors the org plain flag", () => { - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPathProduction: "nope", deployBuildPath: "native" }, - }) - ).toEqual({ path: "native", source: "organization" }); - }); - - it("does not treat an explicit disableNativeBuildServer: false as an opt-out", () => { - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPath: "native" }, - projectBuildSettings: { disableNativeBuildServer: false }, - }) - ).toEqual({ path: "native", source: "organization" }); - }); - - it("falls back to depot when the native build server is not available", () => { - expect( - resolveDeployBuildPath({ - ...base, - orgFeatureFlags: { deployBuildPath: "native" }, - nativeBuildServerAvailable: false, - }) - ).toEqual({ path: "depot", source: "unavailable" }); - }); -}); diff --git a/apps/webapp/test/deploySettingsRoute.test.ts b/apps/webapp/test/deploySettingsRoute.test.ts index 99368a11ac4..a93d03060f6 100644 --- a/apps/webapp/test/deploySettingsRoute.test.ts +++ b/apps/webapp/test/deploySettingsRoute.test.ts @@ -1,24 +1,19 @@ +import { errAsync, okAsync } from "neverthrow"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), - findFirst: vi.fn<(...args: any[]) => Promise>(), - isBillingConfigured: vi.fn<() => boolean>(), - current: vi.fn<() => Record | undefined>(), - flags: vi.fn<() => Promise>>(), + getDeploySettings: vi.fn<(...args: any[]) => any>(), })); vi.mock("~/services/apiAuth.server", () => ({ authenticateApiKeyWithScope: mocks.authenticateApiKeyWithScope, })); -vi.mock("~/db.server", () => ({ $replica: { project: { findFirst: mocks.findFirst } } })); -vi.mock("~/services/platform.v3.server", () => ({ - isBillingConfigured: mocks.isBillingConfigured, +vi.mock("~/v3/services/deployment.server", () => ({ + DeploymentService: class { + getDeploySettings = mocks.getDeploySettings; + }, })); -vi.mock("~/v3/globalFlagsRegistry.server", () => ({ - globalFlagsRegistry: { current: mocks.current }, -})); -vi.mock("~/v3/featureFlags.server", () => ({ flags: mocks.flags })); vi.mock("~/services/logger.server", () => ({ logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn() }, })); @@ -48,18 +43,14 @@ function load(env = "prod", projectRef = "proj_ref") { describe("deploy settings route", () => { beforeEach(() => { mocks.authenticateApiKeyWithScope.mockReset(); - mocks.findFirst.mockReset(); - mocks.isBillingConfigured.mockReset(); - mocks.current.mockReset(); - mocks.flags.mockReset(); + mocks.getDeploySettings.mockReset(); mocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, authentication: { environment: environment() }, }); - mocks.findFirst.mockResolvedValue({ buildSettings: null }); - mocks.isBillingConfigured.mockReturnValue(true); - mocks.current.mockReturnValue({}); - mocks.flags.mockResolvedValue({}); + mocks.getDeploySettings.mockReturnValue( + okAsync({ buildPath: "depot", buildPathSource: "default" }) + ); }); it("rejects an unknown env slug before authenticating", async () => { @@ -77,69 +68,37 @@ describe("deploy settings route", () => { const response = await load(); expect(response.status).toBe(401); expect(await response.json()).toEqual({ error: "Invalid API key" }); + expect(mocks.getDeploySettings).not.toHaveBeenCalled(); }); it("refuses a key that belongs to another project or environment type", async () => { expect((await load("prod", "proj_other")).status).toBe(403); expect((await load("staging")).status).toBe(403); + expect(mocks.getDeploySettings).not.toHaveBeenCalled(); }); - it("accepts a preview branch environment on the preview slug", async () => { + it("returns only the build path, resolved for the authenticated environment", async () => { + const env = environment({ type: "PREVIEW" }); mocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, - authentication: { environment: environment({ type: "PREVIEW" }) }, + authentication: { environment: env }, }); + mocks.getDeploySettings.mockReturnValue( + okAsync({ buildPath: "native", buildPathSource: "organization_environment" }) + ); + const response = await load("preview"); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ build: { path: "depot", source: "default" } }); - }); - - it("resolves from the org flags and the registry snapshot without hitting flags()", async () => { - mocks.authenticateApiKeyWithScope.mockResolvedValue({ - ok: true, - authentication: { - environment: environment({ - organization: { featureFlags: { deployBuildPathProduction: "native" } }, - }), - }, - }); - mocks.current.mockReturnValue({ deployBuildPath: "depot" }); - const response = await load(); - expect(await response.json()).toEqual({ - build: { path: "native", source: "organization_environment" }, - }); - expect(mocks.flags).not.toHaveBeenCalled(); - }); - - it("falls back to flags() when the registry is cold", async () => { - mocks.current.mockReturnValue(undefined); - mocks.flags.mockResolvedValue({ deployBuildPath: "native" }); - expect(await (await load()).json()).toEqual({ build: { path: "native", source: "global" } }); - expect(mocks.flags).toHaveBeenCalledTimes(1); - }); - - it("honors the project opt-out even when other build settings are malformed", async () => { - mocks.current.mockReturnValue({ deployBuildPath: "native" }); - mocks.findFirst.mockResolvedValue({ - buildSettings: { installCommand: null, disableNativeBuildServer: true }, - }); - expect(await (await load()).json()).toEqual({ - build: { path: "depot", source: "project_opt_out" }, - }); - }); - - it("reports native as unavailable without billing", async () => { - mocks.isBillingConfigured.mockReturnValue(false); - mocks.current.mockReturnValue({ deployBuildPath: "native" }); - expect(await (await load()).json()).toEqual({ - build: { path: "depot", source: "unavailable" }, - }); + expect(await response.json()).toEqual({ build_path: "native" }); + expect(mocks.getDeploySettings).toHaveBeenCalledWith(env); }); - it("returns 500 when the project lookup fails", async () => { - mocks.findFirst.mockRejectedValue(new Error("db down")); + it("returns 500 when the service fails", async () => { + mocks.getDeploySettings.mockReturnValue( + errAsync({ type: "other", cause: new Error("db down") }) + ); const response = await load(); expect(response.status).toBe(500); - expect(await response.json()).toEqual({ error: "Internal Server Error" }); + expect(await response.json()).toEqual({ error: "Internal server error" }); }); }); diff --git a/apps/webapp/test/deploymentServiceDeploySettings.test.ts b/apps/webapp/test/deploymentServiceDeploySettings.test.ts new file mode 100644 index 00000000000..e1ee31f0b8c --- /dev/null +++ b/apps/webapp/test/deploymentServiceDeploySettings.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isBillingConfigured: vi.fn<() => boolean>(), + current: vi.fn<() => Record | undefined>(), + flags: vi.fn<() => Promise>>(), +})); + +vi.mock("~/services/platform.v3.server", async (importOriginal) => ({ + ...(await importOriginal()), + isBillingConfigured: mocks.isBillingConfigured, +})); +vi.mock("~/v3/globalFlagsRegistry.server", () => ({ + globalFlagsRegistry: { current: mocks.current }, +})); +vi.mock("~/v3/featureFlags.server", async (importOriginal) => ({ + ...(await importOriginal()), + flags: mocks.flags, +})); + +import { DeploymentService } from "~/v3/services/deployment.server"; + +type EnvType = "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION"; + +function resolve(type: EnvType, orgFeatureFlags: unknown = {}) { + return new DeploymentService().getDeploySettings({ + type, + organization: { featureFlags: orgFeatureFlags }, + } as any); +} + +async function path(type: EnvType, orgFeatureFlags: unknown = {}) { + const result = await resolve(type, orgFeatureFlags); + if (result.isErr()) throw result.error.cause; + return [result.value.buildPath, result.value.buildPathSource]; +} + +describe("DeploymentService.getDeploySettings", () => { + beforeEach(() => { + mocks.isBillingConfigured.mockReset().mockReturnValue(true); + mocks.current.mockReset().mockReturnValue({}); + mocks.flags.mockReset().mockResolvedValue({}); + }); + + it("is depot when the native build server is unavailable, whatever the flags say", async () => { + mocks.isBillingConfigured.mockReturnValue(false); + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION", { deployBuildPath: "native" })).toEqual([ + "depot", + "unavailable", + ]); + }); + + it("defaults to depot when nothing is set", async () => { + expect(await path("PRODUCTION")).toEqual(["depot", "default"]); + }); + + it("applies the plain global flag to every environment type", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const type of ["DEVELOPMENT", "PREVIEW", "STAGING", "PRODUCTION"] as const) { + expect(await path(type)).toEqual(["native", "global"]); + } + }); + + it("prefers the global env-type flag over the plain global flag", async () => { + mocks.current.mockReturnValue({ + deployBuildPath: "native", + deployBuildPathProduction: "depot", + }); + expect(await path("PRODUCTION")).toEqual(["depot", "global_environment"]); + expect(await path("STAGING")).toEqual(["native", "global"]); + }); + + it("lets the org plain flag beat every global flag", async () => { + mocks.current.mockReturnValue({ + deployBuildPath: "native", + deployBuildPathProduction: "native", + }); + expect(await path("PRODUCTION", { deployBuildPath: "depot" })).toEqual([ + "depot", + "organization", + ]); + + mocks.current.mockReturnValue({ deployBuildPath: "depot" }); + expect(await path("PRODUCTION", { deployBuildPath: "native" })).toEqual([ + "native", + "organization", + ]); + }); + + it("prefers the org env-type flag over the org plain flag", async () => { + const org = { deployBuildPath: "native", deployBuildPathPreview: "native_local_bundle" }; + expect(await path("PREVIEW", org)).toEqual(["native_local_bundle", "organization_environment"]); + expect(await path("PRODUCTION", org)).toEqual(["native", "organization"]); + }); + + it("never lets another environment type's key leak", async () => { + mocks.current.mockReturnValue({ deployBuildPathStaging: "native" }); + expect(await path("PRODUCTION", { deployBuildPathPreview: "native" })).toEqual([ + "depot", + "default", + ]); + expect(await path("DEVELOPMENT", { deployBuildPathProduction: "native" })).toEqual([ + "depot", + "default", + ]); + }); + + it("skips values the schema rejects instead of treating them as depot", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION", { deployBuildPathProduction: "bogus" })).toEqual([ + "native", + "global", + ]); + expect( + await path("PRODUCTION", { deployBuildPathProduction: null, deployBuildPath: 1 }) + ).toEqual(["native", "global"]); + }); + + it("tolerates a malformed org flag blob", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const blob of [null, undefined, "native", 42, ["native"]]) { + expect(await path("PRODUCTION", blob)).toEqual(["native", "global"]); + } + }); + + it("reads the registry snapshot without calling flags()", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + await path("PRODUCTION"); + expect(mocks.flags).not.toHaveBeenCalled(); + }); + + it("falls back to flags() when the registry is cold", async () => { + mocks.current.mockReturnValue(undefined); + mocks.flags.mockResolvedValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION")).toEqual(["native", "global"]); + expect(mocks.flags).toHaveBeenCalledTimes(1); + }); + + it("returns an error when the global flags cannot be loaded", async () => { + mocks.current.mockReturnValue(undefined); + mocks.flags.mockRejectedValue(new Error("db down")); + const result = await resolve("PRODUCTION"); + expect(result.isErr()).toBe(true); + expect(result.isErr() && result.error).toMatchObject({ type: "other" }); + }); +}); diff --git a/docs/snippets/cli-commands-deploy.mdx b/docs/snippets/cli-commands-deploy.mdx index f776a5ee793..ce8125836aa 100644 --- a/docs/snippets/cli-commands-deploy.mdx +++ b/docs/snippets/cli-commands-deploy.mdx @@ -102,14 +102,6 @@ npx trigger.dev@latest deploy [path] Force building the deployment image locally using your local Docker. This is automatic when self-hosting. - - Build the image on the native build server, ignoring the build path configured for the project on the server. - - - - Build the image with Depot, ignoring the build path configured for the project on the server. - - ### Common options These options are available on most commands. diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 06dcb7b5595..4f16570fa40 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -7,7 +7,6 @@ import { } from "@trigger.dev/core/v3"; import type { DeployBuildPath, - DeployBuildPathSource, InitializeDeploymentRequestBody, InitializeDeploymentResponseBody, GitMeta, @@ -1284,22 +1283,6 @@ const BUILD_PATH_LABEL: Record = { native_local_bundle: "Building on the native build server from a local bundle", }; -const BUILD_PATH_SOURCE_LABEL: Record = { - default: "server default", - global: "configured on the server", - global_environment: "configured on the server for this environment type", - organization: "configured for your organization", - organization_environment: "configured for your organization's environments of this type", - project_opt_out: "the native build server is disabled in the project settings", - unavailable: "the native build server is not available on this server", -}; - -/** - * Explicit path flags (--native-build, --depot-build, --local-build) win and skip the server - * round-trip; --local-bundle and --detach require --native-build, so they never depend on the - * server either. Otherwise the server decides (org/env-type feature flags); any failure to ask - * falls open to Depot, the path older CLIs use unconditionally. - */ async function resolveBuildPath( apiClient: CliApiClient, projectRef: string, @@ -1336,18 +1319,15 @@ async function resolveBuildPath( return "depot"; } - const { path, source } = result.data.build; + const buildPath = result.data.build_path; - if (path !== "depot") { - const sourceLabel = - BUILD_PATH_SOURCE_LABEL[source as DeployBuildPathSource] ?? - `configured on the server: ${source}`; - log.info(`${BUILD_PATH_LABEL[path]} (${sourceLabel})`); + if (buildPath === "depot") { + logger.debug("Build path depot (server)"); } else { - logger.debug(`Build path depot (${source})`); + log.info(BUILD_PATH_LABEL[buildPath]); } - return path; + return buildPath; } async function handleNativeBuildServerDeploy({ diff --git a/packages/cli-v3/src/deploy/buildPath.ts b/packages/cli-v3/src/deploy/buildPath.ts index 759ff066664..98dc71765b9 100644 --- a/packages/cli-v3/src/deploy/buildPath.ts +++ b/packages/cli-v3/src/deploy/buildPath.ts @@ -7,22 +7,12 @@ export type BuildPathOptions = { dryRun: boolean; }; -/** - * --local-bundle and --detach only exist on the native build server path, so they must be - * paired with --native-build. Returns the error message for the first violation, if any. - */ export function nativeOnlyFlagError(options: BuildPathOptions): string | undefined { if (options.nativeBuildServer) return undefined; const flag = options.localBundle ? "--local-bundle" : options.detach ? "--detach" : undefined; return flag ? `${flag} requires --native-build.` : undefined; } -/** - * Applies the modifiers to the resolved build path. --local-bundle turns the native path - * into its local-bundle variant. A dry run never reaches the plain native path (it has no - * dry-run mode and would deploy for real); it is bundled locally on the Depot path instead. - * The local-bundle path handles dry runs itself. - */ export function applyBuildPathOptions( resolved: DeployBuildPath, options: BuildPathOptions @@ -31,6 +21,7 @@ export function applyBuildPathOptions( return "native_local_bundle"; } + // A plain native dry run would deploy for real, so bundle locally on the Depot path instead. if (options.dryRun && resolved === "native") { return "depot"; } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ce675429ce7..5175f94cb19 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -843,24 +843,8 @@ export const DeployBuildPath = z.enum(["depot", "native", "native_local_bundle"] export type DeployBuildPath = z.infer; -export const DeployBuildPathSource = z.enum([ - "default", - "global", - "global_environment", - "organization", - "organization_environment", - "project_opt_out", - "unavailable", -]); - -export type DeployBuildPathSource = z.infer; - -// `source` is informational, so the CLI accepts values newer servers may add. export const GetDeploySettingsResponseBody = z.object({ - build: z.object({ - path: DeployBuildPath, - source: z.string(), - }), + build_path: DeployBuildPath, }); export type GetDeploySettingsResponseBody = z.infer; From 30f6535af00574ef482df9f94e835f4eeba104e7 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 16:27:16 +0200 Subject: [PATCH 12/15] refactor(deploy-settings): typed service errors and a testable CLI resolver DeploymentService.getDeploySettings now also checks that the URL's project ref and env slug belong to the authenticated environment, returning `environment_mismatch` / `failed_to_load_global_flags` so the route is a single `.match`, and rethrows a thrown Response like the sibling deployment routes. The CLI's server/flag/fallback decision moves to deploy/buildPath.ts with an injected fetch so the 404-silent and warn-on-failure branches are unit tested. --- ...ojects.$projectRef.$env.deploy-settings.ts | 69 ++++++++----------- .../app/v3/services/deployment.server.ts | 63 ++++++++++++----- apps/webapp/test/deploySettingsRoute.test.ts | 33 ++++++--- .../deploymentServiceDeploySettings.test.ts | 49 +++++++++++-- packages/cli-v3/src/commands/deploy.ts | 60 +++++++--------- packages/cli-v3/src/deploy/buildPath.test.ts | 60 +++++++++++++++- packages/cli-v3/src/deploy/buildPath.ts | 45 +++++++++++- 7 files changed, 271 insertions(+), 108 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts index bd7daf5e92d..54a805932bf 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts @@ -10,13 +10,6 @@ const ParamsSchema = z.object({ env: z.enum(["dev", "staging", "prod", "preview"]), }); -const ENV_SLUG_FOR_TYPE = { - DEVELOPMENT: "dev", - STAGING: "staging", - PRODUCTION: "prod", - PREVIEW: "preview", -} as const; - export async function loader({ request, params }: LoaderFunctionArgs) { const parsedParams = ParamsSchema.safeParse(params); @@ -35,45 +28,43 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: authResult.error }, { status: authResult.status }); } - const { environment } = authResult.authentication; + const { environment: authenticatedEnv } = authResult.authentication; const { projectRef, env } = parsedParams.data; - if ( - environment.project.externalRef !== projectRef || - ENV_SLUG_FOR_TYPE[environment.type] !== env - ) { - return json( - { error: "API key does not belong to this project environment" }, - { status: 403 } - ); - } - const deploymentService = new DeploymentService(); - return await deploymentService.getDeploySettings(environment).match( - ({ buildPath, buildPathSource }) => { - logger.info("Resolved deploy build path", { - environmentId: environment.id, - projectRef, - env, - buildPath, - buildPathSource, - }); + return await deploymentService + .getDeploySettings(authenticatedEnv, { projectRef, envSlug: env }) + .match( + ({ buildPath, buildPathSource }) => { + logger.info("Resolved deploy build path", { + environmentId: authenticatedEnv.id, + projectRef, + env, + buildPath, + buildPathSource, + }); - return json({ build_path: buildPath } satisfies GetDeploySettingsResponseBody); - }, - (error) => { - switch (error.type) { - case "other": - default: - error.type satisfies "other"; - logger.error("Failed to resolve deploy settings", { error: error.cause }); - return json({ error: "Internal server error" }, { status: 500 }); + return json({ build_path: buildPath } satisfies GetDeploySettingsResponseBody); + }, + (error) => { + switch (error.type) { + case "environment_mismatch": + return json( + { error: "API key does not belong to this project environment" }, + { status: 403 } + ); + case "failed_to_load_global_flags": + default: + error.type satisfies "failed_to_load_global_flags"; + logger.error("Failed to load the global feature flags", { error: error.cause }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } } - } - ); + ); } catch (error) { + if (error instanceof Response) throw error; logger.error("Failed to resolve deploy settings", { error }); - return json({ error: "Internal server error" }, { status: 500 }); + return json({ error: "Internal Server Error" }, { status: 500 }); } } diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 9a4343d5d53..173eed87d20 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -43,6 +43,15 @@ const DEPLOY_BUILD_PATH_ENV_FLAG: Partial = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +}; + +export type DeployEnvSlug = "dev" | "staging" | "prod" | "preview"; + export type DeployBuildPathSource = | "unavailable" | "organization_environment" @@ -51,6 +60,8 @@ export type DeployBuildPathSource = | "global" | "default"; +type DeploySettings = { buildPath: DeployBuildPath; buildPathSource: DeployBuildPathSource }; + export class DeploymentService extends BaseService { /** * Progresses a deployment from PENDING to INSTALLING and then to BUILDING. @@ -306,29 +317,33 @@ export class DeploymentService extends BaseService { } public getDeploySettings( - authenticatedEnv: Pick - ): ResultAsync< - { buildPath: DeployBuildPath; buildPathSource: DeployBuildPathSource }, - { type: "other"; cause: unknown } - > { - if (!isBillingConfigured()) { - return okAsync({ buildPath: "depot" as const, buildPathSource: "unavailable" as const }); - } + authenticatedEnv: Pick, + target: { projectRef: string; envSlug: DeployEnvSlug } + ) { + const validateTarget = (): ResultAsync => { + if ( + authenticatedEnv.project.externalRef !== target.projectRef || + DEPLOY_ENV_SLUG_FOR_TYPE[authenticatedEnv.type] !== target.envSlug + ) { + return errAsync({ type: "environment_mismatch" as const }); + } + return okAsync(undefined); + }; const loadGlobalFlags = () => fromPromise(Promise.resolve(globalFlagsRegistry.current() ?? flags()), (error) => ({ - type: "other" as const, + type: "failed_to_load_global_flags" as const, cause: error, })); - const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type]; - const orgFlags = authenticatedEnv.organization.featureFlags; - const orgFlagSet: Record = - orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags) - ? (orgFlags as Record) - : {}; + const pickBuildPath = (globalFlagSet: Record): DeploySettings => { + const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type]; + const orgFlags = authenticatedEnv.organization.featureFlags; + const orgFlagSet: Record = + orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags) + ? (orgFlags as Record) + : {}; - return loadGlobalFlags().map((globalFlagSet: Record) => { const candidates: Array< [Record, FeatureFlagKey | undefined, DeployBuildPathSource] > = [ @@ -346,8 +361,20 @@ export class DeploymentService extends BaseService { } } - return { buildPath: "depot" as const, buildPathSource: "default" as const }; - }); + return { buildPath: "depot", buildPathSource: "default" }; + }; + + const resolveBuildPath = (): ResultAsync< + DeploySettings, + { type: "failed_to_load_global_flags"; cause: unknown } + > => { + if (!isBillingConfigured()) { + return okAsync({ buildPath: "depot" as const, buildPathSource: "unavailable" as const }); + } + return loadGlobalFlags().map(pickBuildPath); + }; + + return validateTarget().andThen(resolveBuildPath); } /** diff --git a/apps/webapp/test/deploySettingsRoute.test.ts b/apps/webapp/test/deploySettingsRoute.test.ts index a93d03060f6..18c46a0cdb5 100644 --- a/apps/webapp/test/deploySettingsRoute.test.ts +++ b/apps/webapp/test/deploySettingsRoute.test.ts @@ -71,10 +71,14 @@ describe("deploy settings route", () => { expect(mocks.getDeploySettings).not.toHaveBeenCalled(); }); - it("refuses a key that belongs to another project or environment type", async () => { - expect((await load("prod", "proj_other")).status).toBe(403); - expect((await load("staging")).status).toBe(403); - expect(mocks.getDeploySettings).not.toHaveBeenCalled(); + it("maps an environment mismatch to 403", async () => { + mocks.getDeploySettings.mockReturnValue(errAsync({ type: "environment_mismatch" })); + const response = await load("prod", "proj_other"); + expect(response.status).toBe(403); + expect(mocks.getDeploySettings).toHaveBeenCalledWith(environment(), { + projectRef: "proj_other", + envSlug: "prod", + }); }); it("returns only the build path, resolved for the authenticated environment", async () => { @@ -90,15 +94,28 @@ describe("deploy settings route", () => { const response = await load("preview"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ build_path: "native" }); - expect(mocks.getDeploySettings).toHaveBeenCalledWith(env); + expect(mocks.getDeploySettings).toHaveBeenCalledWith(env, { + projectRef: "proj_ref", + envSlug: "preview", + }); + expect(mocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), { + action: "read", + resource: { type: "deployments" }, + }); }); - it("returns 500 when the service fails", async () => { + it("returns 500 when the global flags cannot be loaded", async () => { mocks.getDeploySettings.mockReturnValue( - errAsync({ type: "other", cause: new Error("db down") }) + errAsync({ type: "failed_to_load_global_flags", cause: new Error("db down") }) ); const response = await load(); expect(response.status).toBe(500); - expect(await response.json()).toEqual({ error: "Internal server error" }); + expect(await response.json()).toEqual({ error: "Internal Server Error" }); + }); + + it("rethrows a Response thrown by authentication", async () => { + const thrown = new Response(null, { status: 429 }); + mocks.authenticateApiKeyWithScope.mockRejectedValue(thrown); + await expect(load()).rejects.toBe(thrown); }); }); diff --git a/apps/webapp/test/deploymentServiceDeploySettings.test.ts b/apps/webapp/test/deploymentServiceDeploySettings.test.ts index e1ee31f0b8c..3109b8fe0e3 100644 --- a/apps/webapp/test/deploymentServiceDeploySettings.test.ts +++ b/apps/webapp/test/deploymentServiceDeploySettings.test.ts @@ -21,12 +21,28 @@ vi.mock("~/v3/featureFlags.server", async (importOriginal) => ({ import { DeploymentService } from "~/v3/services/deployment.server"; type EnvType = "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION"; - -function resolve(type: EnvType, orgFeatureFlags: unknown = {}) { - return new DeploymentService().getDeploySettings({ - type, - organization: { featureFlags: orgFeatureFlags }, - } as any); +type EnvSlug = "dev" | "staging" | "prod" | "preview"; + +const SLUG: Record = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +}; + +function resolve( + type: EnvType, + orgFeatureFlags: unknown = {}, + target = { projectRef: "proj_ref", envSlug: SLUG[type] } +) { + return new DeploymentService().getDeploySettings( + { + type, + project: { externalRef: "proj_ref" }, + organization: { featureFlags: orgFeatureFlags }, + } as any, + target + ); } async function path(type: EnvType, orgFeatureFlags: unknown = {}) { @@ -42,6 +58,25 @@ describe("DeploymentService.getDeploySettings", () => { mocks.flags.mockReset().mockResolvedValue({}); }); + it("rejects a target that is not the key's project or environment type", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const target of [ + { projectRef: "proj_other", envSlug: "prod" as const }, + { projectRef: "proj_ref", envSlug: "staging" as const }, + { projectRef: "proj_ref", envSlug: "preview" as const }, + ]) { + const result = await resolve("PRODUCTION", {}, target); + expect(result.isErr() && result.error).toEqual({ type: "environment_mismatch" }); + } + expect(mocks.flags).not.toHaveBeenCalled(); + }); + + it("accepts every environment type on its own slug", async () => { + for (const type of ["DEVELOPMENT", "PREVIEW", "STAGING", "PRODUCTION"] as const) { + expect(await path(type)).toEqual(["depot", "default"]); + } + }); + it("is depot when the native build server is unavailable, whatever the flags say", async () => { mocks.isBillingConfigured.mockReturnValue(false); mocks.current.mockReturnValue({ deployBuildPath: "native" }); @@ -142,6 +177,6 @@ describe("DeploymentService.getDeploySettings", () => { mocks.flags.mockRejectedValue(new Error("db down")); const result = await resolve("PRODUCTION"); expect(result.isErr()).toBe(true); - expect(result.isErr() && result.error).toMatchObject({ type: "other" }); + expect(result.isErr() && result.error).toMatchObject({ type: "failed_to_load_global_flags" }); }); }); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 4f16570fa40..33116168b65 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -26,7 +26,11 @@ 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 { applyBuildPathOptions, nativeOnlyFlagError } from "../deploy/buildPath.js"; +import { + applyBuildPathOptions, + nativeOnlyFlagError, + resolveBuildPath, +} from "../deploy/buildPath.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -472,7 +476,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } - const resolvedBuildPath = await resolveBuildPath( + const resolvedBuildPath = await resolveServerBuildPath( projectClient.client, resolvedConfig.project, options @@ -1283,22 +1287,12 @@ const BUILD_PATH_LABEL: Record = { native_local_bundle: "Building on the native build server from a local bundle", }; -async function resolveBuildPath( +async function resolveServerBuildPath( apiClient: CliApiClient, projectRef: string, options: DeployCommandOptions ): Promise { - if (options.nativeBuildServer) { - logger.debug("Build path from --native-build"); - return "native"; - } - - if (options.depotBuild || options.localBuild) { - logger.debug(`Build path from ${options.localBuild ? "--local-build" : "--depot-build"}`); - return "depot"; - } - - const [error, result] = await tryCatch( + const resolved = await resolveBuildPath(options, () => apiClient.getDeploySettings( projectRef, options.env, @@ -1306,28 +1300,26 @@ async function resolveBuildPath( ) ); - if (error || !result.success) { - const failure = error ?? (result && !result.success ? result : undefined); - logger.debug("Failed to fetch deploy settings", { failure }); - - // A 404 is an older server without the endpoint; Depot is exactly what it expects. - const is404 = !error && result && !result.success && result.statusCode === 404; - if (!is404) { - log.warn("Could not fetch the deploy settings from the server, using the Depot build path"); - } - - return "depot"; - } - - const buildPath = result.data.build_path; - - if (buildPath === "depot") { - logger.debug("Build path depot (server)"); - } else { - log.info(BUILD_PATH_LABEL[buildPath]); + switch (resolved.from) { + case "flag": + logger.debug(`Build path ${resolved.buildPath} from ${resolved.flag}`); + break; + case "fallback": + logger.debug("Failed to fetch deploy settings", { failure: resolved.failure }); + if (!resolved.silent) { + log.warn("Could not read the deploy settings from the server, using the Depot build path"); + } + break; + case "server": + if (resolved.buildPath === "depot") { + logger.debug("Build path depot (server)"); + } else { + log.info(BUILD_PATH_LABEL[resolved.buildPath]); + } + break; } - return buildPath; + return resolved.buildPath; } async function handleNativeBuildServerDeploy({ diff --git a/packages/cli-v3/src/deploy/buildPath.test.ts b/packages/cli-v3/src/deploy/buildPath.test.ts index 0bd2e40bbab..9ae613089cb 100644 --- a/packages/cli-v3/src/deploy/buildPath.test.ts +++ b/packages/cli-v3/src/deploy/buildPath.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyBuildPathOptions, nativeOnlyFlagError } from "./buildPath.js"; +import { applyBuildPathOptions, nativeOnlyFlagError, resolveBuildPath } from "./buildPath.js"; const none = { nativeBuildServer: false, localBundle: false, detach: false, dryRun: false }; const native = { ...none, nativeBuildServer: true }; @@ -54,3 +54,61 @@ describe("applyBuildPathOptions", () => { ); }); }); + +describe("resolveBuildPath", () => { + const noFlags = { nativeBuildServer: false, depotBuild: false, localBuild: false }; + const neverFetch = () => { + throw new Error("fetchSettings must not be called"); + }; + + it("lets explicit flags decide without asking the server", async () => { + expect(await resolveBuildPath({ ...noFlags, nativeBuildServer: true }, neverFetch)).toEqual({ + buildPath: "native", + from: "flag", + flag: "--native-build", + }); + expect(await resolveBuildPath({ ...noFlags, depotBuild: true }, neverFetch)).toEqual({ + buildPath: "depot", + from: "flag", + flag: "--depot-build", + }); + expect(await resolveBuildPath({ ...noFlags, localBuild: true }, neverFetch)).toEqual({ + buildPath: "depot", + from: "flag", + flag: "--local-build", + }); + }); + + it("uses the server's build path", async () => { + for (const build_path of ["depot", "native", "native_local_bundle"] as const) { + expect( + await resolveBuildPath(noFlags, async () => ({ success: true, data: { build_path } })) + ).toEqual({ buildPath: build_path, from: "server" }); + } + }); + + it("falls back to depot silently on a 404", async () => { + const resolved = await resolveBuildPath(noFlags, async () => ({ + success: false, + statusCode: 404, + })); + expect(resolved).toMatchObject({ buildPath: "depot", from: "fallback", silent: true }); + }); + + it("falls back to depot loudly on any other failure", async () => { + const failures: Array<() => Promise> = [ + async () => ({ success: false, statusCode: 500 }), + async () => ({ success: false }), + async () => { + throw new Error("timeout"); + }, + ]; + for (const fetchSettings of failures) { + expect(await resolveBuildPath(noFlags, fetchSettings)).toMatchObject({ + buildPath: "depot", + from: "fallback", + silent: false, + }); + } + }); +}); diff --git a/packages/cli-v3/src/deploy/buildPath.ts b/packages/cli-v3/src/deploy/buildPath.ts index 98dc71765b9..3dc7b8fe542 100644 --- a/packages/cli-v3/src/deploy/buildPath.ts +++ b/packages/cli-v3/src/deploy/buildPath.ts @@ -1,4 +1,5 @@ -import type { DeployBuildPath } from "@trigger.dev/core/v3/schemas"; +import type { DeployBuildPath, GetDeploySettingsResponseBody } from "@trigger.dev/core/v3/schemas"; +import { tryCatch } from "@trigger.dev/core/v3"; export type BuildPathOptions = { nativeBuildServer: boolean; @@ -28,3 +29,45 @@ export function applyBuildPathOptions( return resolved; } + +export type BuildPathFlags = { + nativeBuildServer?: boolean; + depotBuild?: boolean; + localBuild?: boolean; +}; + +export type DeploySettingsResult = + | { success: true; data: GetDeploySettingsResponseBody } + | { success: false; statusCode?: number }; + +export type ResolvedBuildPath = + | { buildPath: DeployBuildPath; from: "flag"; flag: string } + | { buildPath: DeployBuildPath; from: "server" } + | { buildPath: "depot"; from: "fallback"; silent: boolean; failure: unknown }; + +export async function resolveBuildPath( + options: BuildPathFlags, + fetchSettings: () => Promise +): Promise { + if (options.nativeBuildServer) { + return { buildPath: "native", from: "flag", flag: "--native-build" }; + } + + if (options.localBuild) { + return { buildPath: "depot", from: "flag", flag: "--local-build" }; + } + + if (options.depotBuild) { + return { buildPath: "depot", from: "flag", flag: "--depot-build" }; + } + + const [error, result] = await tryCatch(fetchSettings()); + + if (error || !result.success) { + // A 404 is an older server without the endpoint; Depot is exactly what it expects. + const silent = !error && !result.success && result.statusCode === 404; + return { buildPath: "depot", from: "fallback", silent, failure: error ?? result }; + } + + return { buildPath: result.data.build_path, from: "server" }; +} From 98f890366a048ddc542614d1b6418ce32f071b37 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 16:31:38 +0200 Subject: [PATCH 13/15] fix(webapp): drop unused exports from the deploy settings types --- apps/webapp/app/v3/services/deployment.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 173eed87d20..5729053db79 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -50,9 +50,9 @@ const DEPLOY_ENV_SLUG_FOR_TYPE: Record = PREVIEW: "preview", }; -export type DeployEnvSlug = "dev" | "staging" | "prod" | "preview"; +type DeployEnvSlug = "dev" | "staging" | "prod" | "preview"; -export type DeployBuildPathSource = +type DeployBuildPathSource = | "unavailable" | "organization_environment" | "organization" From cd7cd5f6e02fde1fefa19f70592da1881734b91d Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 16:45:37 +0200 Subject: [PATCH 14/15] fix(cli): do not announce a server-selected build path on dry runs --- packages/cli-v3/src/commands/deploy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 33116168b65..adbce2f26da 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1311,8 +1311,8 @@ async function resolveServerBuildPath( } break; case "server": - if (resolved.buildPath === "depot") { - logger.debug("Build path depot (server)"); + if (resolved.buildPath === "depot" || options.dryRun) { + logger.debug(`Build path ${resolved.buildPath} (server)`); } else { log.info(BUILD_PATH_LABEL[resolved.buildPath]); } From 5d4573e368e1262048e5271041e440ff4621b69b Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 27 Aug 2026 17:15:14 +0200 Subject: [PATCH 15/15] fix(cli): log the server-selected build path at debug level only --- packages/cli-v3/src/commands/deploy.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index adbce2f26da..f2661f412d4 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1281,12 +1281,6 @@ function getTriggeredVia(): DeploymentTriggeredVia { const DEPLOY_SETTINGS_TIMEOUT_MS = 5_000; -const BUILD_PATH_LABEL: Record = { - depot: "Building with Depot", - native: "Building on the native build server", - native_local_bundle: "Building on the native build server from a local bundle", -}; - async function resolveServerBuildPath( apiClient: CliApiClient, projectRef: string, @@ -1311,11 +1305,7 @@ async function resolveServerBuildPath( } break; case "server": - if (resolved.buildPath === "depot" || options.dryRun) { - logger.debug(`Build path ${resolved.buildPath} (server)`); - } else { - log.info(BUILD_PATH_LABEL[resolved.buildPath]); - } + logger.debug(`Build path ${resolved.buildPath} (server)`); break; }