feat(webapp/deployments): Vercel improvements & fixes (#3037)

This commit is contained in:
Oskar Otwinowski
2026-02-13 14:06:22 +01:00
committed by GitHub
parent bfe417f679
commit 4b7f67604a
8 changed files with 360 additions and 63 deletions
+9
View File
@@ -31,6 +31,15 @@
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug opened test file",
"command": "pnpm run test -- ./${relativeFile}",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
@@ -47,6 +47,7 @@ import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresent
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
function safeRedirectUrl(url: string): string | null {
try {
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
const fetcher = useTypedFetcher<typeof loader>();
const envMappingFetcher = useFetcher();
@@ -172,6 +174,33 @@ export function VercelOnboardingModal({
prevIsOpenRef.current = isOpen;
}, [isOpen, state, computeInitialState]);
const trackOnboarding = useCallback(
(eventName: string, extraProperties?: Record<string, unknown>) => {
capture(eventName, {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: state,
organization_slug: organizationSlug,
project_slug: projectSlug,
...extraProperties,
});
},
[capture, fromMarketplaceContext, state, organizationSlug, projectSlug]
);
const hasTrackedStartRef = useRef(false);
const hasTrackedCompletionRef = useRef(false);
useEffect(() => {
if (isOpen && state === "project-selection" && !hasTrackedStartRef.current) {
hasTrackedStartRef.current = true;
startSessionRecording();
trackOnboarding("vercel onboarding started");
}
if (!isOpen) {
hasTrackedStartRef.current = false;
hasTrackedCompletionRef.current = false;
}
}, [isOpen, state, trackOnboarding, startSessionRecording]);
const [selectedVercelProject, setSelectedVercelProject] = useState<{
id: string;
name: string;
@@ -337,6 +366,9 @@ export function VercelOnboardingModal({
useEffect(() => {
if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") {
trackOnboarding("vercel onboarding project selected", {
vercel_project_name: selectedVercelProject?.name,
});
setState("loading-env-mapping");
if (onDataReload) {
onDataReload();
@@ -344,7 +376,7 @@ export function VercelOnboardingModal({
} else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") {
setProjectSelectionError(fetcher.data.error);
}
}, [state, fetcher.data, fetcher.state, onDataReload]);
}, [state, fetcher.data, fetcher.state, onDataReload, trackOnboarding, selectedVercelProject?.name]);
// For marketplace origin, skip env-mapping step
useEffect(() => {
@@ -449,14 +481,23 @@ export function VercelOnboardingModal({
method: "post",
action: actionUrl,
});
}, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]);
}, [actionUrl, fetcher, onClose, fromMarketplaceContext]);
const handleSkipEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: true,
staging_environment: null,
});
setVercelStagingEnvironment(null);
setState("loading-env-vars");
}, []);
}, [trackOnboarding]);
const handleUpdateEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: false,
staging_environment: vercelStagingEnvironment?.displayName ?? null,
});
if (!vercelStagingEnvironment) {
setState("loading-env-vars");
return;
@@ -471,9 +512,11 @@ export function VercelOnboardingModal({
action: actionUrl,
});
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl]);
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl, trackOnboarding]);
const handleBuildSettingsNext = useCallback(() => {
trackOnboarding("vercel onboarding build settings completed");
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
setIsRedirecting(true);
}
@@ -501,7 +544,7 @@ export function VercelOnboardingModal({
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
}
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]);
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -530,10 +573,14 @@ export function VercelOnboardingModal({
}, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]);
useEffect(() => {
if (state === "completed") {
if (state === "completed" && !hasTrackedCompletionRef.current) {
hasTrackedCompletionRef.current = true;
trackOnboarding("vercel onboarding completed", {
github_connected: isGitHubConnectedForOnboarding,
});
onClose();
}
}, [state, onClose]);
}, [state, onClose, trackOnboarding, isGitHubConnectedForOnboarding]);
useEffect(() => {
if (state === "installing") {
@@ -565,6 +612,12 @@ export function VercelOnboardingModal({
}
}, [state, customEnvironments, vercelStagingEnvironment]);
useEffect(() => {
if (state === "project-selection" && availableProjects.length > 0 && !selectedVercelProject) {
setSelectedVercelProject(availableProjects[0]);
}
}, [state, availableProjects, selectedVercelProject]);
if (!isOpen || onboardingData?.authInvalid) {
return null;
}
@@ -578,7 +631,14 @@ export function VercelOnboardingModal({
if (isLoadingState) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state as string !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -601,7 +661,14 @@ export function VercelOnboardingModal({
const showGitHubConnection = state === "github-connection";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -625,6 +692,7 @@ export function VercelOnboardingModal({
</Callout>
) : (
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
@@ -634,6 +702,7 @@ export function VercelOnboardingModal({
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="tertiary/medium"
placeholder="Select a Vercel project"
dropdownIcon
@@ -894,6 +963,10 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding env vars configured", {
env_vars_enabled: enabledEnvVars.length,
env_vars_total: syncableEnvVars.length,
});
if (fromMarketplaceContext) {
handleBuildSettingsNext();
} else {
+16 -1
View File
@@ -1,6 +1,6 @@
import { useLocation } from "@remix-run/react";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useOrganizationChanged } from "./useOrganizations";
import { useOptionalUser, useUserChanged } from "./useUser";
import { useProjectChanged } from "./useProject";
@@ -68,3 +68,18 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
posthog.capture("$pageview");
}, [location, logging]);
};
export function usePostHogTracking() {
const capture = useCallback(
(eventName: string, properties?: Record<string, unknown>) => {
posthog.capture(eventName, properties);
},
[]
);
const startSessionRecording = useCallback(() => {
posthog.startSessionRecording();
}, []);
return { capture, startSessionRecording };
}
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
buildServerMetadata: body.data.buildServerMetadata,
})
.match(
() => {
@@ -2,7 +2,12 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService } from "./baseService.server";
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
import { type WorkerDeployment, type Project } from "@trigger.dev/database";
import { BuildServerMetadata, logger, type GitMeta, type DeploymentEvent } from "@trigger.dev/core/v3";
import {
BuildServerMetadata,
logger,
type GitMeta,
type DeploymentEvent,
} from "@trigger.dev/core/v3";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { env } from "~/env.server";
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
@@ -38,9 +43,20 @@ export class DeploymentService extends BaseService {
public progressDeployment(
authenticatedEnv: AuthenticatedEnvironment,
friendlyId: string,
updates: Partial<Pick<WorkerDeployment, "contentHash" | "runtime"> & { git: GitMeta }>
updates: Partial<
Pick<WorkerDeployment, "contentHash" | "runtime"> & {
git: GitMeta;
buildServerMetadata: BuildServerMetadata;
}
>
) {
const validateDeployment = (deployment: Pick<WorkerDeployment, "id" | "status"> & { buildServerMetadata?: BuildServerMetadata }) => {
const { buildServerMetadata: newBuildServerMetadata, ...restUpdates } = updates;
const validateDeployment = (
deployment: Pick<WorkerDeployment, "id" | "status"> & {
buildServerMetadata?: BuildServerMetadata;
}
) => {
if (deployment.status !== "PENDING" && deployment.status !== "INSTALLING") {
logger.warn(
"Attempted progressing deployment that is not in PENDING or INSTALLING status",
@@ -54,12 +70,24 @@ export class DeploymentService extends BaseService {
return okAsync(deployment);
};
const progressToInstalling = (deployment: Pick<WorkerDeployment, "id">) =>
fromPromise(
const progressToInstalling = (
deployment: Pick<WorkerDeployment, "id"> & { buildServerMetadata?: BuildServerMetadata }
) => {
const existingBuildServerMetadata = deployment.buildServerMetadata;
return fromPromise(
this._prisma.workerDeployment.updateMany({
where: { id: deployment.id, status: "PENDING" }, // status could've changed in the meantime, we're not locking the row
data: {
...updates,
...restUpdates,
...(newBuildServerMetadata
? {
buildServerMetadata: {
...(existingBuildServerMetadata ?? {}),
...newBuildServerMetadata,
},
}
: {}),
status: "INSTALLING",
startedAt: new Date(),
},
@@ -74,6 +102,7 @@ export class DeploymentService extends BaseService {
}
return okAsync({ id: deployment.id, status: "INSTALLING" as const });
});
};
const progressToBuilding = (
deployment: Pick<WorkerDeployment, "id"> & { buildServerMetadata?: BuildServerMetadata }
@@ -85,13 +114,26 @@ export class DeploymentService extends BaseService {
cause: error,
}));
const existingBuildServerMetadata = deployment.buildServerMetadata as
| BuildServerMetadata
| null
| undefined;
return createRemoteBuildIfNeeded
.andThen((externalBuildData) =>
fromPromise(
this._prisma.workerDeployment.updateMany({
where: { id: deployment.id, status: "INSTALLING" }, // status could've changed in the meantime, we're not locking the row
data: {
...updates,
...restUpdates,
...(newBuildServerMetadata
? {
buildServerMetadata: {
...(existingBuildServerMetadata ?? {}),
...newBuildServerMetadata,
},
}
: {}),
externalBuildData,
status: "BUILDING",
installedAt: new Date(),
@@ -200,6 +200,7 @@ export class InitializeDeploymentService extends BaseService {
artifactKey: payload.artifactKey,
skipPromotion: payload.skipPromotion,
configFilePath: payload.configFilePath,
skipEnqueue: payload.skipEnqueue,
}
: {}),
}
@@ -238,7 +239,8 @@ export class InitializeDeploymentService extends BaseService {
new Date(Date.now() + timeoutMs)
);
if (payload.isNativeBuild) {
// For github integration there is no artifactKey, hence we skip it here
if (payload.isNativeBuild && payload.artifactKey && !payload.skipEnqueue) {
const result = await deploymentService
.enqueueBuild(environment, deployment, payload.artifactKey, {
skipPromotion: payload.skipPromotion,
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { InitializeDeploymentRequestBody } from "./api.js";
import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js";
describe("InitializeDeploymentRequestBody", () => {
const base = { contentHash: "abc123" };
describe("non-native build variant (isNativeBuild omitted or false)", () => {
it("parses with only required fields", () => {
const result = InitializeDeploymentRequestBody.safeParse(base);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isNativeBuild).toBe(false);
}
});
it("parses with isNativeBuild explicitly false", () => {
const result = InitializeDeploymentRequestBody.safeParse({ ...base, isNativeBuild: false });
expect(result.success).toBe(true);
});
it("parses with optional base fields", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
userId: "user_1",
type: "MANAGED",
runtime: "node",
initialStatus: "PENDING",
});
expect(result.success).toBe(true);
});
it("strips native-only fields when isNativeBuild is false", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
isNativeBuild: false,
skipPromotion: true,
});
// Zod discriminatedUnion matches the non-native branch and strips unknown keys
expect(result.success).toBe(true);
if (result.success) {
expect("skipPromotion" in result.data).toBe(false);
}
});
});
describe("native build variant (isNativeBuild: true)", () => {
it("parses with isNativeBuild true", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
isNativeBuild: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isNativeBuild).toBe(true);
}
});
it("parses with native-specific optional fields", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
isNativeBuild: true,
skipPromotion: true,
artifactKey: "artifact_abc",
configFilePath: "trigger.config.ts",
skipEnqueue: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.skipPromotion).toBe(true);
expect(result.data.artifactKey).toBe("artifact_abc");
expect(result.data.configFilePath).toBe("trigger.config.ts");
expect(result.data.skipEnqueue).toBe(true);
}
});
it("skipEnqueue defaults to false when omitted", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
isNativeBuild: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.skipEnqueue).toBe(false);
}
});
});
describe("rejects invalid inputs", () => {
it("rejects missing contentHash", () => {
const result = InitializeDeploymentRequestBody.safeParse({});
expect(result.success).toBe(false);
});
it("rejects invalid type enum value", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
type: "INVALID",
});
expect(result.success).toBe(false);
});
it("rejects invalid initialStatus enum value", () => {
const result = InitializeDeploymentRequestBody.safeParse({
...base,
initialStatus: "RUNNING",
});
expect(result.success).toBe(false);
});
});
describe("type-level checks", () => {
it("native variant exposes native-specific fields", () => {
const result = InitializeDeploymentRequestBody.parse({
...base,
isNativeBuild: true,
skipPromotion: true,
});
if (result.isNativeBuild === true) {
const _skipPromotion: boolean | undefined = result.skipPromotion;
const _artifactKey: string | undefined = result.artifactKey;
const _configFilePath: string | undefined = result.configFilePath;
expect(_skipPromotion).toBe(true);
expect(_artifactKey).toBeUndefined();
expect(_configFilePath).toBeUndefined();
}
});
it("non-native variant narrows correctly", () => {
const result: InitializeDeploymentRequestBodyType =
InitializeDeploymentRequestBody.parse(base);
if (!result.isNativeBuild) {
// Should only have base fields — native-specific fields should not exist
const narrowed: { isNativeBuild?: false; contentHash: string } = result;
expect(narrowed.contentHash).toBe("abc123");
}
});
});
});
+59 -45
View File
@@ -485,10 +485,22 @@ export const FinalizeDeploymentRequestBody = z.object({
export type FinalizeDeploymentRequestBody = z.infer<typeof FinalizeDeploymentRequestBody>;
export const BuildServerMetadata = z.object({
buildId: z.string().optional(),
isNativeBuild: z.boolean().optional(),
artifactKey: z.string().optional(),
skipPromotion: z.boolean().optional(),
configFilePath: z.string().optional(),
skipEnqueue: z.boolean().optional(),
});
export type BuildServerMetadata = z.infer<typeof BuildServerMetadata>;
export const ProgressDeploymentRequestBody = z.object({
contentHash: z.string().optional(),
gitMeta: GitMeta.optional(),
runtime: z.string().optional(),
buildServerMetadata: BuildServerMetadata.optional(),
});
export type ProgressDeploymentRequestBody = z.infer<typeof ProgressDeploymentRequestBody>;
@@ -528,16 +540,6 @@ export const DeploymentTriggeredVia = z
export type DeploymentTriggeredVia = z.infer<typeof DeploymentTriggeredVia>;
export const BuildServerMetadata = z.object({
buildId: z.string().optional(),
isNativeBuild: z.boolean().optional(),
artifactKey: z.string().optional(),
skipPromotion: z.boolean().optional(),
configFilePath: z.string().optional(),
});
export type BuildServerMetadata = z.infer<typeof BuildServerMetadata>;
export const UpsertBranchRequestBody = z.object({
git: GitMeta.optional(),
env: z.enum(["preview"]),
@@ -590,41 +592,53 @@ export const InitializeDeploymentResponseBody = z.object({
export type InitializeDeploymentResponseBody = z.infer<typeof InitializeDeploymentResponseBody>;
export const InitializeDeploymentRequestBody = z
.object({
contentHash: z.string(),
userId: z.string().optional(),
/** @deprecated This is now determined by the webapp. This is only used to warn users with old CLI versions. */
selfHosted: z.boolean().optional(),
gitMeta: GitMeta.optional(),
type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(),
runtime: z.string().optional(),
initialStatus: z.enum(["PENDING", "BUILDING"]).optional(),
triggeredVia: DeploymentTriggeredVia.optional(),
buildId: z.string().optional(),
})
.and(
z.preprocess(
(val) => {
const obj = val as any;
if (!obj || !obj.isNativeBuild) {
return { ...obj, isNativeBuild: false };
}
return obj;
},
z.discriminatedUnion("isNativeBuild", [
z.object({
isNativeBuild: z.literal(true),
skipPromotion: z.boolean(),
artifactKey: z.string(),
configFilePath: z.string().optional(),
}),
z.object({
isNativeBuild: z.literal(false),
}),
])
)
);
const InitializeDeploymentRequestBodyBase = z.object({
contentHash: z.string(),
userId: z.string().optional(),
/** @deprecated This is now determined by the webapp. This is only used to warn users with old CLI versions. */
selfHosted: z.boolean().optional(),
gitMeta: GitMeta.optional(),
type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(),
runtime: z.string().optional(),
initialStatus: z.enum(["PENDING", "BUILDING"]).optional(),
triggeredVia: DeploymentTriggeredVia.optional(),
buildId: z.string().optional()
});
type BaseOutput = z.output<typeof InitializeDeploymentRequestBodyBase>;
type NativeBuildOutput = BaseOutput & {
isNativeBuild: true;
skipPromotion?: boolean;
artifactKey?: string;
configFilePath?: string;
skipEnqueue?: boolean;
};
type NonNativeBuildOutput = BaseOutput & {
isNativeBuild: false;
skipPromotion?: never;
artifactKey?: never;
configFilePath?: never;
skipEnqueue?: never;
};
const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({
isNativeBuild: z.boolean().default(false),
skipPromotion: z.boolean().optional(),
artifactKey: z.string().optional(),
configFilePath: z.string().optional(),
skipEnqueue: z.boolean().optional().default(false),
});
export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform(
(data): NativeBuildOutput | NonNativeBuildOutput => {
if (data.isNativeBuild) {
return { ...data, isNativeBuild: true as const };
}
const { skipPromotion, artifactKey, configFilePath, skipEnqueue, ...rest } = data;
return { ...rest, isNativeBuild: false as const };
}
);
export type InitializeDeploymentRequestBody = z.infer<typeof InitializeDeploymentRequestBody>;