diff --git a/.server-changes/vercel-staging-gating.md b/.server-changes/vercel-staging-gating.md new file mode 100644 index 00000000000..c0000314efd --- /dev/null +++ b/.server-changes/vercel-staging-gating.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The Staging branch setting now shows an upgrade prompt on plans that don't include a Staging environment, instead of looking editable and then silently doing nothing when saved. diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index e3be9a4f90e..56bc785368c 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -49,7 +49,6 @@ type BuildSettingsFieldsProps = { * the pin status is unknown — distinct from "not set". */ currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ - hideSectionToggles?: boolean; showAtomicDeployments?: boolean; layout?: "settings" | "card"; }; @@ -68,7 +67,6 @@ export function BuildSettingsFields({ onAutoPromoteChange, currentTriggerVersion, currentTriggerVersionFetchFailed, - hideSectionToggles, showAtomicDeployments = true, layout = "card", }: BuildSettingsFieldsProps) { @@ -222,7 +220,7 @@ export function BuildSettingsFields({
- {!hideSectionToggles && availableEnvSlugs.length > 1 && ( + {availableEnvSlugs.length > 1 && (
- {!hideSectionToggles && availableEnvSlugs.length > 1 && ( + {availableEnvSlugs.length > 1 && ( void; vercelManageAccessUrl?: string; }) { const { capture, startSessionRecording } = usePostHogTracking(); - const navigation = useNavigation(); const fetcher = useTypedFetcher(); const envMappingFetcher = useFetcher(); const completeOnboardingFetcher = useFetcher(); - const { Form: _CompleteOnboardingForm } = completeOnboardingFetcher; const [searchParams] = useSearchParams(); const origin = searchParams.get("origin"); const fromMarketplaceContext = origin === "marketplace"; @@ -130,7 +127,6 @@ export function VercelOnboardingModal({ () => onboardingData?.availableProjects ?? [], [onboardingData?.availableProjects] ); - const _hasProjectSelected = onboardingData?.hasProjectSelected ?? false; const customEnvironments = useMemo( () => onboardingData?.customEnvironments ?? [], [onboardingData?.customEnvironments] @@ -224,10 +220,6 @@ export function VercelOnboardingModal({ environmentId: string; displayName: string; } | null>(null); - const _availableEnvSlugsForOnboarding = getAvailableEnvSlugs( - hasStagingEnvironment, - hasPreviewEnvironment - ); const availableEnvSlugsForOnboardingBuildSettings = getAvailableEnvSlugsForBuildSettings( hasStagingEnvironment, hasPreviewEnvironment @@ -375,7 +367,6 @@ export function VercelOnboardingModal({ } break; - case "installing": case "project-selection": case "env-mapping": case "env-var-sync": @@ -459,8 +450,6 @@ export function VercelOnboardingModal({ const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length; - const _isSubmitting = navigation.state === "submitting" || navigation.state === "loading"; - const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { @@ -634,19 +623,6 @@ export function VercelOnboardingModal({ gitHubAppInstallations.length, ]); - const _handleFinishOnboarding = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const formData = new FormData(form); - completeOnboardingFetcher.submit(formData, { - method: "post", - action: actionUrl, - }); - }, - [completeOnboardingFetcher, actionUrl] - ); - useEffect(() => { if ( completeOnboardingFetcher.data && @@ -698,13 +674,6 @@ export function VercelOnboardingModal({ } }, [state, onClose, trackOnboarding, isGitHubConnectedForOnboarding]); - useEffect(() => { - if (state === "installing") { - const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); - window.location.href = installUrl; - } - }, [state, organizationSlug, projectSlug]); - useEffect(() => { if ( envMappingFetcher.data && @@ -749,7 +718,6 @@ export function VercelOnboardingModal({ state === "loading-projects" || state === "loading-env-mapping" || state === "loading-env-vars" || - state === "installing" || (state === "idle" && !onboardingData); if (isLoadingState) { @@ -758,9 +726,7 @@ export function VercelOnboardingModal({ open={isOpen} onOpenChange={(open) => { if (!open && !fromMarketplaceContext) { - if ((state as string) !== "completed") { - trackOnboarding("vercel onboarding abandoned"); - } + trackOnboarding("vercel onboarding abandoned"); onClose(); } }} @@ -772,9 +738,30 @@ export function VercelOnboardingModal({ Set up Vercel Integration
-
- -
+ {onboardingDataUnavailable ? ( +
+ + We couldn't load your Vercel projects. The integration may have been removed or lost + access to this organization on Vercel. + +
+ {onDataReload && ( + + )} + {vercelManageAccessUrl && ( + + Manage access on Vercel + + )} +
+
+ ) : ( +
+ +
+ )} ); diff --git a/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts index 53bd034f249..162e44f8a88 100644 --- a/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/GitHubSettingsPresenter.server.ts @@ -19,6 +19,7 @@ export class GitHubSettingsPresenter extends BasePresenter { connectedRepository: undefined, installations: undefined, isPreviewEnvironmentEnabled: undefined, + isStagingEnvironmentEnabled: undefined, }); } @@ -114,7 +115,8 @@ export class GitHubSettingsPresenter extends BasePresenter { }, where: { projectId: projectId, - slug: "preview", + type: "PREVIEW", + parentEnvironmentId: null, }, }), (error) => ({ @@ -123,15 +125,42 @@ export class GitHubSettingsPresenter extends BasePresenter { }) ).map((previewEnvironment) => previewEnvironment !== null); + const isStagingEnvironmentEnabled = () => + fromPromise( + (this._replica as PrismaClient).runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId: projectId, + type: "STAGING", + parentEnvironmentId: null, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((stagingEnvironment) => stagingEnvironment !== null); + return ResultAsync.combine([ isPreviewEnvironmentEnabled(), + isStagingEnvironmentEnabled(), findConnectedGithubRepository(), listGithubAppInstallations(), - ]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({ - enabled: true, - connectedRepository: connectedGithubRepository, - installations: githubAppInstallations, - isPreviewEnvironmentEnabled, - })); + ]).map( + ([ + isPreviewEnvironmentEnabled, + isStagingEnvironmentEnabled, + connectedGithubRepository, + githubAppInstallations, + ]) => ({ + enabled: true, + connectedRepository: connectedGithubRepository, + installations: githubAppInstallations, + isPreviewEnvironmentEnabled, + isStagingEnvironmentEnabled, + }) + ); } } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 10a46c01b3a..841c929d141 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -182,6 +182,7 @@ export class VercelSettingsPresenter extends BasePresenter { where: { projectId, type: "STAGING", + parentEnvironmentId: null, }, }), (error) => ({ @@ -199,6 +200,7 @@ export class VercelSettingsPresenter extends BasePresenter { where: { projectId, type: "PREVIEW", + parentEnvironmentId: null, }, }), (error) => ({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index 864cc300fa4..37ad3d51681 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -59,8 +59,9 @@ export const loader = dashboardLoader( async ({ params, user, ability }) => { const { projectParam, organizationSlug } = params; + const canManageBuildSettings = ability.can("write", { type: "github" }); const canManageIntegrations = - ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" }); + canManageBuildSettings || ability.can("write", { type: "vercel" }); if (!canManageIntegrations) { throwPermissionDenied("With your current role, you can't manage integrations."); @@ -102,6 +103,7 @@ export const loader = dashboardLoader( githubAppEnabled: gitHubApp.enabled, buildSettings, vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, + canManageBuildSettings, }); } ); @@ -208,7 +210,7 @@ export const action = dashboardAction( ); export default function IntegrationsSettingsPage() { - const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled, canManageBuildSettings } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); @@ -223,6 +225,8 @@ export default function IntegrationsSettingsPage() { const loadVercelOnboarding = vercelFetcher.load; const onboardingData = vercelFetcher.data?.onboardingData ?? null; const hasVercelFetcherData = vercelFetcher.data !== undefined; + const onboardingDataUnavailable = + hasVercelFetcherData && vercelFetcher.state === "idle" && onboardingData === null; const vercelOnboardingPath = `${vercelResourcePath( organization.slug, project.slug, @@ -375,24 +379,27 @@ export default function IntegrationsSettingsPage() { /> )} - - - - Applies to deployments triggered from GitHub, and CLI deployments run with the{" "} - - --native-build-server - {" "} - flag. - - } - /> - - )} + + + + Applies to deployments triggered from GitHub, and CLI deployments run with the{" "} + + --native-build-server + {" "} + flag. + + } + /> + + {/* Vercel Onboarding Modal */} @@ -407,6 +414,7 @@ export default function IntegrationsSettingsPage() { hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false} hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false} hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false} + onboardingDataUnavailable={onboardingDataUnavailable} nextUrl={nextUrl ?? undefined} vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl} onDataReload={(vercelEnvironmentId) => { @@ -424,7 +432,13 @@ export default function IntegrationsSettingsPage() { ); } -function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { +function BuildSettingsForm({ + buildSettings, + canManageBuildSettings = true, +}: { + buildSettings: BuildSettings; + canManageBuildSettings?: boolean; +}) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); @@ -572,7 +586,12 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) name="action" value="update-build-settings" variant="secondary/small" - disabled={isBuildSettingsLoading || !hasBuildSettingsChanges} + disabled={isBuildSettingsLoading || !hasBuildSettingsChanges || !canManageBuildSettings} + tooltip={ + canManageBuildSettings + ? undefined + : "You don't have permission to manage build settings" + } LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined} > Save diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index 4421b76daf1..fba94b80bba 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -779,6 +779,7 @@ function GitHubSettingsRows({ export function ConnectedGitHubRepoForm({ connectedGitHubRepo, previewEnvironmentEnabled, + stagingEnvironmentEnabled, organizationSlug, projectSlug, environmentSlug, @@ -788,6 +789,7 @@ export function ConnectedGitHubRepoForm({ }: { connectedGitHubRepo: ConnectedGitHubRepo; previewEnvironmentEnabled?: boolean; + stagingEnvironmentEnabled?: boolean; organizationSlug: string; projectSlug: string; environmentSlug: string; @@ -956,24 +958,42 @@ export function ConnectedGitHubRepoForm({ { - setGitSettingsValues((prev) => ({ - ...prev, - stagingBranch: e.target.value, - })); - }} - /> + stagingEnvironmentEnabled ? ( + { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} + /> + ) : ( + + Upgrade + + ) } > - + void; @@ -698,19 +695,6 @@ function VercelGitHubWarning() { ); } -function envSlugLabel(slug: EnvSlug): string { - switch (slug) { - case "prod": - return "Production"; - case "stg": - return "Staging"; - case "preview": - return "Preview"; - case "dev": - return "Development"; - } -} - function ConnectedVercelProjectForm({ connectedProject, hasStagingEnvironment, @@ -774,7 +758,7 @@ function ConnectedVercelProjectForm({ stagingEnvChanged || autoPromoteChanged; - const [configForm, _fields] = useForm({ + const [configForm] = useForm({ id: "update-vercel-config", lastResult: lastSubmission, shouldRevalidate: "onSubmit", @@ -833,26 +817,22 @@ function ConnectedVercelProjectForm({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); - const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings( hasStagingEnvironment, hasPreviewEnvironment ); + const hasVercelCustomEnvironments = customEnvironments.length > 0; + const disabledEnvSlugsForBuildSettings: Partial> | undefined = hasStagingEnvironment && !configValues.vercelStagingEnvironment - ? { stg: "Set a Vercel environment for Staging first." } + ? { + stg: hasVercelCustomEnvironments + ? "Set a Vercel environment for Staging first." + : "Add a custom environment to this project in Vercel to use Staging.", + } : undefined; - const _formatSelectedEnvs = ( - selected: EnvSlug[], - availableSlugs: EnvSlug[] = availableEnvSlugs - ): string => { - if (selected.length === 0) return "None selected"; - if (selected.length === availableSlugs.length) return "All environments"; - return selected.map(envSlugLabel).join(", "); - }; - return ( <> - {/* Staging environment mapping */} - {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && ( + {hasStagingEnvironment && ( - { + if (!Array.isArray(value)) { + const env = customEnvironments?.find((e) => e.id === value); + setConfigValues((prev) => { + const next = { + ...prev, + vercelStagingEnvironment: env + ? { environmentId: env.id, displayName: env.slug } + : null, + }; + // When clearing the staging mapping, strip "stg" from build settings + if (!env) { + next.pullEnvVarsBeforeBuild = prev.pullEnvVarsBeforeBuild.filter( + (s) => s !== "stg" + ); + next.discoverEnvVars = prev.discoverEnvVars.filter((s) => s !== "stg"); + } + return next; + }); + } + }} + items={[{ id: "", slug: "None" }, ...customEnvironments]} + variant="secondary/small" + placeholder="Select environment" + dropdownIcon + text={ + configValues.vercelStagingEnvironment ? ( + + ) : ( + "None" + ) } - }} - items={[{ id: "", slug: "None" }, ...customEnvironments]} - variant="secondary/small" - placeholder="Select environment" - dropdownIcon - text={ - configValues.vercelStagingEnvironment ? ( - - ) : ( - "None" - ) - } - > - {[ - - None - , - ...customEnvironments.map((env) => ( - - - - )), - ]} - -
+ > + {[ + + None + , + ...customEnvironments.map((env) => ( + + + + )), + ]} + +
+ ) } /> )} @@ -1041,7 +1029,6 @@ function ConnectedVercelProjectForm({ } currentTriggerVersion={currentTriggerVersion} currentTriggerVersionFetchFailed={currentTriggerVersionFetchFailed} - hideSectionToggles layout="settings" /> @@ -1205,44 +1192,16 @@ function VercelSettingsPanel({ }) { const fetcher = useTypedFetcher(); const { load } = fetcher; - const _location = useLocation(); const data = fetcher.data; - const [hasError, _setHasError] = useState(false); const [hasFetched, setHasFetched] = useState(false); useEffect(() => { - if (!data?.authInvalid && !hasError && !data && !hasFetched) { + if (!data && !hasFetched) { load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes route state after an external or lifecycle change. setHasFetched(true); } - }, [ - organizationSlug, - projectSlug, - environmentSlug, - data?.authInvalid, - hasError, - data, - hasFetched, - load, - ]); - - if (hasError) { - return ( -
-
- -
-

Failed to load Vercel settings

-

- There was an error loading the Vercel integration settings. Please refresh the page to - try again. -

-
-
-
- ); - } + }, [organizationSlug, projectSlug, environmentSlug, data, hasFetched, load]); if (fetcher.state === "loading" && !data) { return ( @@ -1258,40 +1217,30 @@ function VercelSettingsPanel({ } const showGitHubWarning = data.connectedProject && !data.isGitHubConnected; - const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid; if (data.connectedProject) { return ( <> - {showAuthInvalid && ( - - )} {showGitHubWarning && } - {!showAuthInvalid && } - {!showAuthInvalid && ( - - )} + + ); } - if (showAuthInvalid) { + if (data.authInvalid) { return ( { const installationId = Number(connectedRepo.repository.installation.appInstallationId); + const oldStagingBranch = connectedRepo.branchTracking?.staging?.branch; - return ResultAsync.combine([ - validateProductionBranch({ - installationId, - fullRepoName: connectedRepo.repository.fullName, - oldProductionBranch: connectedRepo.branchTracking?.prod?.branch, - }), - validateStagingBranch({ - installationId, - fullRepoName: connectedRepo.repository.fullName, - oldStagingBranch: connectedRepo.branchTracking?.staging?.branch, - }), - this.isPreviewEnvironmentEnabled(projectId), - ]); + return this.isStagingEnvironmentEnabled(projectId).andThen((stagingEnvironmentEnabled) => + ResultAsync.combine([ + validateProductionBranch({ + installationId, + fullRepoName: connectedRepo.repository.fullName, + oldProductionBranch: connectedRepo.branchTracking?.prod?.branch, + }), + stagingEnvironmentEnabled + ? validateStagingBranch({ + installationId, + fullRepoName: connectedRepo.repository.fullName, + oldStagingBranch, + }) + : okAsync(oldStagingBranch), + this.isPreviewEnvironmentEnabled(projectId), + ]) + ); }) .map(([productionBranch, stagingBranch, previewEnvironmentEnabled]) => ({ productionBranch, @@ -317,7 +322,8 @@ export class ProjectSettingsService { }, where: { projectId: projectId, - slug: "preview", + type: "PREVIEW", + parentEnvironmentId: null, }, }), (error) => ({ @@ -326,4 +332,23 @@ export class ProjectSettingsService { }) ).map((previewEnvironment) => previewEnvironment !== null); } + + private isStagingEnvironmentEnabled(projectId: string) { + return fromPromise( + this.#prismaClient.runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId: projectId, + type: "STAGING", + parentEnvironmentId: null, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((stagingEnvironment) => stagingEnvironment !== null); + } } diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts index 336519af03b..a95bb12b491 100644 --- a/apps/webapp/app/services/vercelIntegration.server.ts +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -20,6 +20,8 @@ import { VercelProjectIntegrationDataSchema, envTypeToSlug, createDefaultVercelIntegrationData, + getAvailableEnvSlugs, + restrictConfigToAvailableEnvSlugs, SKEW_PROTECTION_ENV_VAR_KEY, } from "~/v3/vercel/vercelProjectIntegrationSchema"; @@ -129,6 +131,17 @@ export class VercelIntegrationService { .filter((i): i is VercelProjectIntegrationWithProject => i !== null); } + async #getAvailableEnvSlugs(projectId: string): Promise { + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + where: { projectId, type: { in: ["STAGING", "PREVIEW"] }, parentEnvironmentId: null }, + select: { type: true }, + }); + + const types = new Set(environments.map((environment) => environment.type)); + + return getAvailableEnvSlugs(types.has("STAGING"), types.has("PREVIEW")); + } + async createVercelProjectIntegration(params: { organizationIntegrationId: string; projectId: string; @@ -142,7 +155,8 @@ export class VercelIntegrationService { params.vercelProjectId, params.vercelProjectName, params.vercelTeamId, - params.vercelTeamSlug + params.vercelTeamSlug, + await this.#getAvailableEnvSlugs(params.projectId) ); return this.#prismaClient.organizationProjectIntegration.create({ @@ -183,6 +197,8 @@ export class VercelIntegrationService { () => undefined ); + const availableEnvSlugs = await this.#getAvailableEnvSlugs(params.projectId); + // Use a serializable transaction to prevent duplicate project integrations // from concurrent selectVercelProject calls (read-then-write race condition). const txResult = await $transaction( @@ -236,7 +252,8 @@ export class VercelIntegrationService { params.vercelProjectId, params.vercelProjectName, teamId, - vercelTeamSlug + vercelTeamSlug, + availableEnvSlugs ); const created = await tx.organizationProjectIntegration.create({ @@ -320,7 +337,10 @@ export class VercelIntegrationService { const updatedConfig = { ...existing.parsedIntegrationData.config, - ...configUpdates, + ...restrictConfigToAvailableEnvSlugs( + configUpdates, + await this.#getAvailableEnvSlugs(projectId) + ), }; const updatedData: VercelProjectIntegrationData = { @@ -578,14 +598,20 @@ export class VercelIntegrationService { prod: {}, preview: {}, }; + const availableEnvSlugs = await this.#getAvailableEnvSlugs(projectId); const updatedData: VercelProjectIntegrationData = { ...existing.parsedIntegrationData, config: { ...existing.parsedIntegrationData.config, - pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, - atomicBuilds: params.atomicBuilds ?? null, - discoverEnvVars: params.discoverEnvVars ?? null, - vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, + ...restrictConfigToAvailableEnvSlugs( + { + pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, + atomicBuilds: params.atomicBuilds ?? null, + discoverEnvVars: params.discoverEnvVars ?? null, + vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, + }, + availableEnvSlugs + ), }, //This is intentionally not updated here, in case of resetting the onboarding it should not override the existing mapping with an empty one syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping, @@ -610,7 +636,7 @@ export class VercelIntegrationService { projectId, vercelProjectId: updatedData.vercelProjectId, teamId, - vercelStagingEnvironment: params.vercelStagingEnvironment, + vercelStagingEnvironment: updatedData.config.vercelStagingEnvironment, syncEnvVarsMapping, orgIntegration, }); diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index cde9f708163..c428c7956a7 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -85,13 +85,16 @@ export function createDefaultVercelIntegrationData( vercelProjectId: string, vercelProjectName: string, vercelTeamId: string | null, - vercelTeamSlug?: string + vercelTeamSlug?: string, + availableEnvSlugs: EnvSlug[] = ALL_ENV_SLUGS ): VercelProjectIntegrationData { + const defaultOn = (["prod", "preview"] as EnvSlug[]).filter((s) => availableEnvSlugs.includes(s)); + return { config: { atomicBuilds: [], - pullEnvVarsBeforeBuild: ["prod", "preview"], - discoverEnvVars: ["prod", "preview"], + pullEnvVarsBeforeBuild: defaultOn, + discoverEnvVars: defaultOn, vercelStagingEnvironment: null, autoPromote: true, }, @@ -142,6 +145,26 @@ export function getAvailableEnvSlugs( }); } +export function restrictConfigToAvailableEnvSlugs( + config: Partial, + availableEnvSlugs: EnvSlug[] +): Partial { + const restricted = { ...config }; + + for (const key of ["atomicBuilds", "pullEnvVarsBeforeBuild", "discoverEnvVars"] as const) { + const slugs = restricted[key]; + if (slugs) { + restricted[key] = slugs.filter((slug) => availableEnvSlugs.includes(slug)); + } + } + + if ("vercelStagingEnvironment" in restricted && !availableEnvSlugs.includes("stg")) { + restricted.vercelStagingEnvironment = null; + } + + return restricted; +} + export function getAvailableEnvSlugsForBuildSettings( hasStagingEnvironment: boolean, hasPreviewEnvironment: boolean diff --git a/apps/webapp/test/vercelIntegrationConfig.test.ts b/apps/webapp/test/vercelIntegrationConfig.test.ts new file mode 100644 index 00000000000..696627d70b2 --- /dev/null +++ b/apps/webapp/test/vercelIntegrationConfig.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + createDefaultVercelIntegrationData, + restrictConfigToAvailableEnvSlugs, +} from "../app/v3/vercel/vercelProjectIntegrationSchema"; + +const STAGING_ENV = { environmentId: "env_123", displayName: "Staging" }; + +describe("restrictConfigToAvailableEnvSlugs", () => { + it("drops slugs the project has no environment for", () => { + const restricted = restrictConfigToAvailableEnvSlugs( + { + atomicBuilds: ["prod", "stg"], + pullEnvVarsBeforeBuild: ["prod", "preview"], + discoverEnvVars: ["dev", "stg", "preview"], + }, + ["dev", "prod"] + ); + + expect(restricted.atomicBuilds).toEqual(["prod"]); + expect(restricted.pullEnvVarsBeforeBuild).toEqual(["prod"]); + expect(restricted.discoverEnvVars).toEqual(["dev"]); + }); + + it("keeps slugs the project does have", () => { + const restricted = restrictConfigToAvailableEnvSlugs( + { atomicBuilds: ["prod", "stg", "preview"] }, + ["dev", "stg", "prod", "preview"] + ); + + expect(restricted.atomicBuilds).toEqual(["prod", "stg", "preview"]); + }); + + it("only touches keys present on the input", () => { + const restricted = restrictConfigToAvailableEnvSlugs({ atomicBuilds: ["stg"] }, ["prod"]); + + expect(restricted).not.toHaveProperty("pullEnvVarsBeforeBuild"); + expect(restricted).not.toHaveProperty("discoverEnvVars"); + expect(restricted).not.toHaveProperty("vercelStagingEnvironment"); + }); + + it("clears the staging environment mapping when staging is unavailable", () => { + const restricted = restrictConfigToAvailableEnvSlugs( + { vercelStagingEnvironment: STAGING_ENV }, + ["dev", "prod", "preview"] + ); + + expect(restricted.vercelStagingEnvironment).toBeNull(); + }); + + it("keeps the staging environment mapping when staging is available", () => { + const restricted = restrictConfigToAvailableEnvSlugs( + { vercelStagingEnvironment: STAGING_ENV }, + ["dev", "stg", "prod"] + ); + + expect(restricted.vercelStagingEnvironment).toEqual(STAGING_ENV); + }); + + it("does not mutate the input", () => { + const config = { atomicBuilds: ["prod", "stg"] as const }; + restrictConfigToAvailableEnvSlugs({ atomicBuilds: [...config.atomicBuilds] }, ["prod"]); + + expect(config.atomicBuilds).toEqual(["prod", "stg"]); + }); +}); + +describe("createDefaultVercelIntegrationData", () => { + it("does not enable preview for a project without a preview environment", () => { + const data = createDefaultVercelIntegrationData("prj_1", "My project", null, undefined, [ + "dev", + "prod", + ]); + + expect(data.config.pullEnvVarsBeforeBuild).toEqual(["prod"]); + expect(data.config.discoverEnvVars).toEqual(["prod"]); + }); + + it("enables preview when the project has a preview environment", () => { + const data = createDefaultVercelIntegrationData("prj_1", "My project", null, undefined, [ + "dev", + "stg", + "prod", + "preview", + ]); + + expect(data.config.pullEnvVarsBeforeBuild).toEqual(["prod", "preview"]); + expect(data.config.discoverEnvVars).toEqual(["prod", "preview"]); + }); + + it("never turns atomic builds on by default", () => { + const data = createDefaultVercelIntegrationData("prj_1", "My project", null, undefined, [ + "dev", + "stg", + "prod", + "preview", + ]); + + expect(data.config.atomicBuilds).toEqual([]); + expect(data.config.vercelStagingEnvironment).toBeNull(); + }); +});