Skip to content

Commit c7b0498

Browse files
authored
feat(cli): server-selected deploy build path (#4803)
The CLI now asks the server which build path to use before it builds or uploads anything, so native builds can be rolled out per organization and per environment type without a CLI release. ``` trigger.dev deploy │ ├─ explicit flag? (--native-build / --local-build / --depot-build) │ └─ yes → use it, never ask the server │ └─ GET /api/v1/projects/:ref/:env/deploy-settings (env API key, 5s timeout, one attempt) │ │ server resolves: native unavailable → org[env type] → org → global[env type] → global → depot │ ├─ { "build_path": "native" | "native_local_bundle" } → that path ├─ { "build_path": "depot" } → Depot └─ error / timeout / 404 → Depot (fail open) ``` The path comes from four enum feature flags, editable in the global and per-org admin flag UIs: `deployBuildPath` and `deployBuildPathPreview` / `Staging` / `Production`. Unset everywhere keeps current behaviour unchanged; CLIs older than this release never call the endpoint and keep their current behaviour.
1 parent acaa5ec commit c7b0498

12 files changed

Lines changed: 787 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
`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`.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { type GetDeploySettingsResponseBody } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
5+
import { logger } from "~/services/logger.server";
6+
import { DeploymentService } from "~/v3/services/deployment.server";
7+
8+
const ParamsSchema = z.object({
9+
projectRef: z.string(),
10+
env: z.enum(["dev", "staging", "prod", "preview"]),
11+
});
12+
13+
export async function loader({ request, params }: LoaderFunctionArgs) {
14+
const parsedParams = ParamsSchema.safeParse(params);
15+
16+
if (!parsedParams.success) {
17+
return json({ error: "Invalid params" }, { status: 400 });
18+
}
19+
20+
try {
21+
const authResult = await authenticateApiKeyWithScope(request, {
22+
action: "read",
23+
resource: { type: "deployments" },
24+
});
25+
26+
if (!authResult.ok) {
27+
logger.info("Invalid or missing api key", { url: request.url });
28+
return json({ error: authResult.error }, { status: authResult.status });
29+
}
30+
31+
const { environment: authenticatedEnv } = authResult.authentication;
32+
const { projectRef, env } = parsedParams.data;
33+
34+
const deploymentService = new DeploymentService();
35+
36+
return await deploymentService
37+
.getDeploySettings(authenticatedEnv, { projectRef, envSlug: env })
38+
.match(
39+
({ buildPath, buildPathSource }) => {
40+
logger.info("Resolved deploy build path", {
41+
environmentId: authenticatedEnv.id,
42+
projectRef,
43+
env,
44+
buildPath,
45+
buildPathSource,
46+
});
47+
48+
return json({ build_path: buildPath } satisfies GetDeploySettingsResponseBody);
49+
},
50+
(error) => {
51+
switch (error.type) {
52+
case "environment_mismatch":
53+
return json(
54+
{ error: "API key does not belong to this project environment" },
55+
{ status: 403 }
56+
);
57+
case "failed_to_load_global_flags":
58+
default:
59+
error.type satisfies "failed_to_load_global_flags";
60+
logger.error("Failed to load the global feature flags", { error: error.cause });
61+
return json({ error: "Internal Server Error" }, { status: 500 });
62+
}
63+
}
64+
);
65+
} catch (error) {
66+
if (error instanceof Response) throw error;
67+
logger.error("Failed to resolve deploy settings", { error });
68+
return json({ error: "Internal Server Error" }, { status: 500 });
69+
}
70+
}

apps/webapp/app/services/deploymentApiPaths.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const deploymentApiPaths: (RegExp | string)[] = [
44
// /current is runtime SDK surface, kept out of the deploy budget
55
/^\/api\/v\d+\/deployments(?!\/current$)(\/|$)/,
66
/^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)$/,
7+
/^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)\/deploy-settings$/,
78
/^\/api\/v1\/projects\/[^/]+\/envvars$/,
89
/^\/api\/v1\/projects\/[^/]+\/envvars\/[^/]+\/import$/,
910
/^\/api\/v1\/projects\/[^/]+\/branches$/,

apps/webapp/app/v3/featureFlags.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { DeployBuildPath } from "@trigger.dev/core/v3";
23

34
export const FEATURE_FLAG = {
45
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
@@ -37,6 +38,11 @@ export const FEATURE_FLAG = {
3738
// Fleet-wide pin for the complete cutover. Beats every per-org and per-env pin.
3839
runOpsMintShardOverride: "runOpsMintShardOverride",
3940
queueMetricsUiEnabled: "queueMetricsUiEnabled",
41+
// Build path for CLI deploys, resolved by DeploymentService.getDeploySettings.
42+
deployBuildPath: "deployBuildPath",
43+
deployBuildPathPreview: "deployBuildPathPreview",
44+
deployBuildPathStaging: "deployBuildPathStaging",
45+
deployBuildPathProduction: "deployBuildPathProduction",
4046
// Per-organization rollout for creating additional environment API keys.
4147
additionalApiKeysEnabled: "additionalApiKeysEnabled",
4248
// System-wide kill switch for issuing additional environment API keys.
@@ -148,6 +154,10 @@ export const FeatureFlagCatalog = {
148154
// Per-org access to the Queue Metrics dashboard UI (view only; emission is global and
149155
// separate). Off unless enabled for the org.
150156
[FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(),
157+
[FEATURE_FLAG.deployBuildPath]: DeployBuildPath,
158+
[FEATURE_FLAG.deployBuildPathPreview]: DeployBuildPath,
159+
[FEATURE_FLAG.deployBuildPathStaging]: DeployBuildPath,
160+
[FEATURE_FLAG.deployBuildPathProduction]: DeployBuildPath,
151161
// Strict booleans prevent a stringified "false" from silently enabling API-key
152162
// creation or lookup. Cold/absent values resolve to the safe `false`.
153163
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),

apps/webapp/app/v3/services/deployment.server.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@ import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
44
import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database";
55
import {
66
BuildServerMetadata,
7+
DeployBuildPath,
78
logger,
89
type GitMeta,
910
type DeploymentEvent,
11+
type RuntimeEnvironmentType,
1012
} from "@trigger.dev/core/v3";
1113
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
1214
import { recordDeploymentFinished } from "./recordDeploymentFinished.server";
1315
import { env } from "~/env.server";
1416
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
1517
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
16-
import { enqueueBuild, generateRegistryCredentials } from "~/services/platform.v3.server";
18+
import {
19+
enqueueBuild,
20+
generateRegistryCredentials,
21+
isBillingConfigured,
22+
} from "~/services/platform.v3.server";
23+
import { FEATURE_FLAG, type FeatureFlagKey } from "../featureFlags";
24+
import { flags } from "../featureFlags.server";
25+
import { globalFlagsRegistry } from "../globalFlagsRegistry.server";
1726
import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore";
1827
import { createRedisClient } from "~/redis.server";
1928

@@ -28,6 +37,31 @@ const s2TokenRedis = createRedisClient("s2-token-cache", {
2837
});
2938
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
3039

40+
const DEPLOY_BUILD_PATH_ENV_FLAG: Partial<Record<RuntimeEnvironmentType, FeatureFlagKey>> = {
41+
PREVIEW: FEATURE_FLAG.deployBuildPathPreview,
42+
STAGING: FEATURE_FLAG.deployBuildPathStaging,
43+
PRODUCTION: FEATURE_FLAG.deployBuildPathProduction,
44+
};
45+
46+
const DEPLOY_ENV_SLUG_FOR_TYPE: Record<RuntimeEnvironmentType, DeployEnvSlug> = {
47+
DEVELOPMENT: "dev",
48+
STAGING: "staging",
49+
PRODUCTION: "prod",
50+
PREVIEW: "preview",
51+
};
52+
53+
type DeployEnvSlug = "dev" | "staging" | "prod" | "preview";
54+
55+
type DeployBuildPathSource =
56+
| "unavailable"
57+
| "organization_environment"
58+
| "organization"
59+
| "global_environment"
60+
| "global"
61+
| "default";
62+
63+
type DeploySettings = { buildPath: DeployBuildPath; buildPathSource: DeployBuildPathSource };
64+
3165
export class DeploymentService extends BaseService {
3266
/**
3367
* Progresses a deployment from PENDING to INSTALLING and then to BUILDING.
@@ -282,6 +316,67 @@ export class DeploymentService extends BaseService {
282316
.map(() => undefined);
283317
}
284318

319+
public getDeploySettings(
320+
authenticatedEnv: Pick<AuthenticatedEnvironment, "type" | "organization" | "project">,
321+
target: { projectRef: string; envSlug: DeployEnvSlug }
322+
) {
323+
const validateTarget = (): ResultAsync<undefined, { type: "environment_mismatch" }> => {
324+
if (
325+
authenticatedEnv.project.externalRef !== target.projectRef ||
326+
DEPLOY_ENV_SLUG_FOR_TYPE[authenticatedEnv.type] !== target.envSlug
327+
) {
328+
return errAsync({ type: "environment_mismatch" as const });
329+
}
330+
return okAsync(undefined);
331+
};
332+
333+
const loadGlobalFlags = () =>
334+
fromPromise(Promise.resolve(globalFlagsRegistry.current() ?? flags()), (error) => ({
335+
type: "failed_to_load_global_flags" as const,
336+
cause: error,
337+
}));
338+
339+
const pickBuildPath = (globalFlagSet: Record<string, unknown>): DeploySettings => {
340+
const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type];
341+
const orgFlags = authenticatedEnv.organization.featureFlags;
342+
const orgFlagSet: Record<string, unknown> =
343+
orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags)
344+
? (orgFlags as Record<string, unknown>)
345+
: {};
346+
347+
const candidates: Array<
348+
[Record<string, unknown>, FeatureFlagKey | undefined, DeployBuildPathSource]
349+
> = [
350+
[orgFlagSet, envKey, "organization_environment"],
351+
[orgFlagSet, FEATURE_FLAG.deployBuildPath, "organization"],
352+
[globalFlagSet, envKey, "global_environment"],
353+
[globalFlagSet, FEATURE_FLAG.deployBuildPath, "global"],
354+
];
355+
356+
for (const [flagSet, key, buildPathSource] of candidates) {
357+
if (!key) continue;
358+
const parsed = DeployBuildPath.safeParse(flagSet[key]);
359+
if (parsed.success) {
360+
return { buildPath: parsed.data, buildPathSource };
361+
}
362+
}
363+
364+
return { buildPath: "depot", buildPathSource: "default" };
365+
};
366+
367+
const resolveBuildPath = (): ResultAsync<
368+
DeploySettings,
369+
{ type: "failed_to_load_global_flags"; cause: unknown }
370+
> => {
371+
if (!isBillingConfigured()) {
372+
return okAsync({ buildPath: "depot" as const, buildPathSource: "unavailable" as const });
373+
}
374+
return loadGlobalFlags().map(pickBuildPath);
375+
};
376+
377+
return validateTarget().andThen(resolveBuildPath);
378+
}
379+
285380
/**
286381
* Generates registry credentials for a deployment. Returns an error if the deployment is in a final state.
287382
*
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { errAsync, okAsync } from "neverthrow";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise<any>>(),
6+
getDeploySettings: vi.fn<(...args: any[]) => any>(),
7+
}));
8+
9+
vi.mock("~/services/apiAuth.server", () => ({
10+
authenticateApiKeyWithScope: mocks.authenticateApiKeyWithScope,
11+
}));
12+
vi.mock("~/v3/services/deployment.server", () => ({
13+
DeploymentService: class {
14+
getDeploySettings = mocks.getDeploySettings;
15+
},
16+
}));
17+
vi.mock("~/services/logger.server", () => ({
18+
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn() },
19+
}));
20+
21+
import { loader } from "~/routes/api.v1.projects.$projectRef.$env.deploy-settings";
22+
23+
function environment(overrides: Record<string, unknown> = {}) {
24+
return {
25+
id: "env_1",
26+
type: "PRODUCTION",
27+
project: { id: "proj_1", externalRef: "proj_ref" },
28+
organization: { featureFlags: {} },
29+
...overrides,
30+
};
31+
}
32+
33+
function load(env = "prod", projectRef = "proj_ref") {
34+
return loader({
35+
request: new Request(
36+
`https://app.example.com/api/v1/projects/${projectRef}/${env}/deploy-settings`
37+
),
38+
params: { projectRef, env },
39+
context: {},
40+
});
41+
}
42+
43+
describe("deploy settings route", () => {
44+
beforeEach(() => {
45+
mocks.authenticateApiKeyWithScope.mockReset();
46+
mocks.getDeploySettings.mockReset();
47+
mocks.authenticateApiKeyWithScope.mockResolvedValue({
48+
ok: true,
49+
authentication: { environment: environment() },
50+
});
51+
mocks.getDeploySettings.mockReturnValue(
52+
okAsync({ buildPath: "depot", buildPathSource: "default" })
53+
);
54+
});
55+
56+
it("rejects an unknown env slug before authenticating", async () => {
57+
const response = await load("nope");
58+
expect(response.status).toBe(400);
59+
expect(mocks.authenticateApiKeyWithScope).not.toHaveBeenCalled();
60+
});
61+
62+
it("passes the auth failure through", async () => {
63+
mocks.authenticateApiKeyWithScope.mockResolvedValue({
64+
ok: false,
65+
status: 401,
66+
error: "Invalid API key",
67+
});
68+
const response = await load();
69+
expect(response.status).toBe(401);
70+
expect(await response.json()).toEqual({ error: "Invalid API key" });
71+
expect(mocks.getDeploySettings).not.toHaveBeenCalled();
72+
});
73+
74+
it("maps an environment mismatch to 403", async () => {
75+
mocks.getDeploySettings.mockReturnValue(errAsync({ type: "environment_mismatch" }));
76+
const response = await load("prod", "proj_other");
77+
expect(response.status).toBe(403);
78+
expect(mocks.getDeploySettings).toHaveBeenCalledWith(environment(), {
79+
projectRef: "proj_other",
80+
envSlug: "prod",
81+
});
82+
});
83+
84+
it("returns only the build path, resolved for the authenticated environment", async () => {
85+
const env = environment({ type: "PREVIEW" });
86+
mocks.authenticateApiKeyWithScope.mockResolvedValue({
87+
ok: true,
88+
authentication: { environment: env },
89+
});
90+
mocks.getDeploySettings.mockReturnValue(
91+
okAsync({ buildPath: "native", buildPathSource: "organization_environment" })
92+
);
93+
94+
const response = await load("preview");
95+
expect(response.status).toBe(200);
96+
expect(await response.json()).toEqual({ build_path: "native" });
97+
expect(mocks.getDeploySettings).toHaveBeenCalledWith(env, {
98+
projectRef: "proj_ref",
99+
envSlug: "preview",
100+
});
101+
expect(mocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), {
102+
action: "read",
103+
resource: { type: "deployments" },
104+
});
105+
});
106+
107+
it("returns 500 when the global flags cannot be loaded", async () => {
108+
mocks.getDeploySettings.mockReturnValue(
109+
errAsync({ type: "failed_to_load_global_flags", cause: new Error("db down") })
110+
);
111+
const response = await load();
112+
expect(response.status).toBe(500);
113+
expect(await response.json()).toEqual({ error: "Internal Server Error" });
114+
});
115+
116+
it("rethrows a Response thrown by authentication", async () => {
117+
const thrown = new Response(null, { status: 429 });
118+
mocks.authenticateApiKeyWithScope.mockRejectedValue(thrown);
119+
await expect(load()).rejects.toBe(thrown);
120+
});
121+
});

0 commit comments

Comments
 (0)