diff --git a/.server-changes/node-runtime-update-banner.md b/.server-changes/node-runtime-update-banner.md
new file mode 100644
index 00000000000..2b4594bc3ef
--- /dev/null
+++ b/.server-changes/node-runtime-update-banner.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+Warn when Production projects still use Node.js 21 and link directly to update instructions
diff --git a/apps/webapp/app/components/billing/OrgBanner.tsx b/apps/webapp/app/components/billing/OrgBanner.tsx
index acf10f2469d..d88d243a3a9 100644
--- a/apps/webapp/app/components/billing/OrgBanner.tsx
+++ b/apps/webapp/app/components/billing/OrgBanner.tsx
@@ -1,4 +1,5 @@
import { useLocation } from "@remix-run/react";
+import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
import { DateTime } from "~/components/primitives/DateTime";
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
@@ -10,11 +11,17 @@ import {
useOrganization,
useBillingLimit,
useCanManageBillingLimits,
+ useHasProjectRuntimeUpdate,
} from "~/hooks/useOrganizations";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
-import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder";
+import {
+ organizationProjectsPath,
+ v3BillingLimitsPath,
+ v3BillingPath,
+ v3QueuesPath,
+} from "~/utils/pathBuilder";
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
function getUpgradeResetDate(): Date {
@@ -30,6 +37,7 @@ export function OrgBanner() {
const project = useOptionalProject();
const environment = useOptionalEnvironment();
const billingLimit = useBillingLimit();
+ const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate();
const currentPlan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
const location = useLocation();
@@ -48,6 +56,7 @@ export function OrgBanner() {
const isArchived = !!(organization && project && environment && environment.archivedAt);
const bannerKind = selectOrgBanner({
+ hasProjectRuntimeUpdate,
billingLimit,
hasExceededFreeTier: currentPlan?.v3Usage.hasExceededFreeTier === true,
showEnvironmentWarning: isPaused || isArchived,
@@ -58,6 +67,8 @@ export function OrgBanner() {
const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits");
switch (bannerKind) {
+ case OrgBannerKind.RuntimeUpdate:
+ return ;
case OrgBannerKind.LimitRejected:
return hideBillingLimitBanner ? null : ;
case OrgBannerKind.LimitGrace:
@@ -77,6 +88,25 @@ export function OrgBanner() {
}
}
+function RuntimeUpdateBanner() {
+ const organization = useOrganization();
+
+ return (
+
+ Review projects
+
+ }
+ >
+ Some Production projects are still running Node.js {NODE_RUNTIME_UPDATE_MAJOR}. Update them
+ and deploy a new version.
+
+ );
+}
+
function LimitRejectedBanner() {
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
diff --git a/apps/webapp/app/components/billing/selectOrgBanner.ts b/apps/webapp/app/components/billing/selectOrgBanner.ts
index c31c6de7795..6c465c541f7 100644
--- a/apps/webapp/app/components/billing/selectOrgBanner.ts
+++ b/apps/webapp/app/components/billing/selectOrgBanner.ts
@@ -1,6 +1,7 @@
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
export enum OrgBannerKind {
+ RuntimeUpdate = "runtime-update",
LimitRejected = "limit-rejected",
LimitGrace = "limit-grace",
NoLimitConfigured = "no-limit",
@@ -10,13 +11,24 @@ export enum OrgBannerKind {
}
export function selectOrgBanner(input: {
+ hasProjectRuntimeUpdate?: boolean;
billingLimit?: BillingLimitResult;
hasExceededFreeTier?: boolean;
showEnvironmentWarning?: boolean;
/** Self-serve billing UI — hide configure-limit prompt for managed customers. */
showSelfServe?: boolean;
}): OrgBannerKind {
- const { billingLimit, hasExceededFreeTier, showEnvironmentWarning, showSelfServe = true } = input;
+ const {
+ hasProjectRuntimeUpdate,
+ billingLimit,
+ hasExceededFreeTier,
+ showEnvironmentWarning,
+ showSelfServe = true,
+ } = input;
+
+ if (hasProjectRuntimeUpdate) {
+ return OrgBannerKind.RuntimeUpdate;
+ }
if (billingLimit?.isConfigured) {
const status = billingLimit.limitState.status;
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index f628d612dd4..ac968110892 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -12,7 +12,7 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
-import { type MatchedOrganization } from "~/hooks/useOrganizations";
+import { type MatchedOrganization, useHasProjectRuntimeUpdate } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationPath,
@@ -51,18 +51,17 @@ export function OrganizationSettingsSideMenu({
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
- hasProjectRuntimeUpdate,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
- hasProjectRuntimeUpdate: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const currentPlan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
+ const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate();
const isAdmin = useHasAdminAccess();
const showBuildInfo = isAdmin || !isManagedCloud;
diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts
index 4cd603b29e8..f8cc9cb062c 100644
--- a/apps/webapp/app/hooks/useOrganizations.ts
+++ b/apps/webapp/app/hooks/useOrganizations.ts
@@ -94,3 +94,11 @@ export function useCanManageBillingLimits(matches?: UIMatch[]) {
});
return data?.canManageBillingLimits === true;
}
+
+export function useHasProjectRuntimeUpdate(matches?: UIMatch[]) {
+ const data = useTypedMatchesData({
+ id: "routes/_app.orgs.$organizationSlug",
+ matches,
+ });
+ return data?.hasProjectRuntimeUpdate === true;
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
index ff0437d6cbd..969a67b2b03 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
@@ -1,5 +1,4 @@
import { Outlet, useRouteLoaderData } from "@remix-run/react";
-import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { VERSION as coreVersion } from "@trigger.dev/core";
import { type ReactNode } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -10,51 +9,15 @@ import {
OrganizationSettingsSideMenu,
} from "~/components/navigation/OrganizationSettingsSideMenu";
import { useOrganization } from "~/hooks/useOrganizations";
-import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
-import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server";
import { rbac } from "~/services/rbac.server";
-import { requireUserId } from "~/services/session.server";
import { ssoController } from "~/services/sso.server";
const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings";
-// The side-menu dot links to the Projects settings page, which requires `read` on
-// `deployments`, so gate the dot on the same ability the page checks.
-async function canReadDeployments({
- request,
- userId,
- organizationSlug,
-}: {
- request: Request;
- userId: string;
- organizationSlug: string;
-}) {
- // Membership-scoped so the dot is never computed against an org the user is not in.
- const organizationId = await resolveOrgIdFromSlugForUser(organizationSlug, userId);
- if (!organizationId) {
- return false;
- }
-
- const auth = await rbac.authenticateAuthorizeSession(
- request,
- { userId, organizationId },
- { action: "read", resource: { type: "deployments" } }
- );
- return auth.ok;
-}
-
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const organizationSlug = params.organizationSlug;
-
- const [isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate] = await Promise.all([
+export const loader = async () => {
+ const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([
rbac.isUsingPlugin(),
ssoController.isUsingPlugin(),
- organizationSlug
- ? canReadDeployments({ request, userId, organizationSlug }).then((canRead) =>
- canRead ? organizationHasProjectRuntimeUpdate({ organizationSlug, userId }) : false
- )
- : Promise.resolve(false),
]);
return typedjson({
buildInfo: {
@@ -66,7 +29,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
} satisfies BuildInfo,
isUsingPlugin,
isSsoUsingPlugin,
- hasProjectRuntimeUpdate,
});
};
@@ -74,13 +36,11 @@ function SettingsChrome({
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
- hasProjectRuntimeUpdate,
children,
}: {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
- hasProjectRuntimeUpdate: boolean;
children: ReactNode;
}) {
const organization = useOrganization();
@@ -93,7 +53,6 @@ function SettingsChrome({
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
isSsoUsingPlugin={isSsoUsingPlugin}
- hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
/>
{children}
@@ -102,15 +61,13 @@ function SettingsChrome({
}
export default function Page() {
- const { buildInfo, isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate } =
- useTypedLoaderData();
+ const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData();
return (
@@ -127,7 +84,6 @@ export function ErrorBoundary() {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
- hasProjectRuntimeUpdate: boolean;
}
| undefined;
@@ -140,7 +96,6 @@ export function ErrorBoundary() {
buildInfo={data.buildInfo}
isUsingPlugin={data.isUsingPlugin}
isSsoUsingPlugin={data.isSsoUsingPlugin}
- hasProjectRuntimeUpdate={data.hasProjectRuntimeUpdate}
>
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
index 26133675e0d..152ffd26102 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
@@ -12,6 +12,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
+import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server";
import { canManageBillingLimits } from "~/services/routeBuilders/permissions.server";
import { requireUser } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -128,6 +129,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
regions,
isUsingRbacPlugin,
isUsingSsoPlugin,
+ organizationHasRuntimeUpdate,
] = await Promise.all([
rbac
.authenticateSession(request, {
@@ -157,10 +159,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
// items. Both calls are cheap and cached.
rbac.isUsingPlugin().catch(() => false),
ssoController.isUsingPlugin().catch(() => false),
+ organizationHasProjectRuntimeUpdate({ organizationId: organization.id }),
]);
const userCanManageBillingLimits = sessionAuth.ok
? canManageBillingLimits(sessionAuth.ability)
: false;
+ const hasProjectRuntimeUpdate =
+ sessionAuth.ok &&
+ sessionAuth.ability.can("read", { type: "deployments" }) &&
+ organizationHasRuntimeUpdate;
let hasExceededFreeTier = false;
let usagePercentage = 0;
@@ -218,6 +225,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
},
widgetLimitPerDashboard,
canManageBillingLimits: userCanManageBillingLimits,
+ hasProjectRuntimeUpdate,
isUsingRbacPlugin,
isUsingSsoPlugin,
});
diff --git a/apps/webapp/app/services/projectRuntimeUpdates.server.ts b/apps/webapp/app/services/projectRuntimeUpdates.server.ts
index 4393fe25344..91823caea9a 100644
--- a/apps/webapp/app/services/projectRuntimeUpdates.server.ts
+++ b/apps/webapp/app/services/projectRuntimeUpdates.server.ts
@@ -86,19 +86,13 @@ export async function listCurrentProductionProjectRuntimes(scope: Scope) {
}
export async function organizationHasProjectRuntimeUpdate({
- organizationSlug,
- userId,
+ organizationId,
}: {
- organizationSlug: string;
- userId: string;
+ organizationId: string;
}): Promise {
const project = await prisma.project.findFirst({
where: {
- organization: {
- slug: organizationSlug,
- deletedAt: null,
- members: { some: { userId } },
- },
+ organizationId,
version: "V3",
deletedAt: null,
environments: {
diff --git a/apps/webapp/test/orgBanner.test.ts b/apps/webapp/test/orgBanner.test.ts
index 73906090829..94dd029fb28 100644
--- a/apps/webapp/test/orgBanner.test.ts
+++ b/apps/webapp/test/orgBanner.test.ts
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner";
describe("selectOrgBanner", () => {
+ it("prioritizes runtime updates over all other banners", () => {
+ expect(
+ selectOrgBanner({
+ hasProjectRuntimeUpdate: true,
+ billingLimit: {
+ isConfigured: true,
+ mode: "plan",
+ cancelInProgressRuns: false,
+ limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" },
+ effectiveAmountCents: 1000,
+ gracePeriodMs: 86_400_000,
+ },
+ hasExceededFreeTier: true,
+ showEnvironmentWarning: true,
+ })
+ ).toBe(OrgBannerKind.RuntimeUpdate);
+ });
+
it("prioritizes limit-rejected over grace and no-limit", () => {
expect(
selectOrgBanner({