Repository: formbricks/formbricks (2025-08-14T06:47:09Z)

Issue:
Update banner shows even though version is newest
### Issue Summary

A user hosts Formbricks via Cloudron and has v3.16 running but the Update Banner still shows

### Expected Behavior

Only show the banner when it's not running the newest version

### Other information (incl. screenshots, Formbricks version, steps to reproduce,...)

_No response_

### Your Environment

None


Discussion:

Patch:
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
index 412b7a1f1990..c555c5131923 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
@@ -2,6 +2,7 @@
 
 import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
 import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
+import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
 import FBLogo from "@/images/formbricks-wordmark.svg";
 import { cn } from "@/lib/cn";
 import { getAccessFlags } from "@/lib/membership/utils";
@@ -229,7 +230,7 @@ export const MainNavigation = ({
         const latestVersionTag = res.data;
         const currentVersionTag = `v${packageJson.version}`;
 
-        if (currentVersionTag !== latestVersionTag) {
+        if (isNewerVersion(currentVersionTag, latestVersionTag)) {
           setLatestVersion(latestVersionTag);
         }
       }
diff --git a/apps/web/app/(app)/environments/[environmentId]/lib/utils.ts b/apps/web/app/(app)/environments/[environmentId]/lib/utils.ts
new file mode 100644
index 000000000000..bbe6df652922
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/lib/utils.ts
@@ -0,0 +1,93 @@
+export interface VersionInfo {
+  major: number;
+  minor: number;
+  patch: number;
+  prerelease?: string;
+}
+
+export const parseVersion = (version: string): VersionInfo | null => {
+  // Remove 'v' prefix if present
+  const cleanVersion = version.replace(/^v/, "");
+
+  // Regex for semantic versioning with optional patch and prerelease (no build metadata)
+  // Supports both 2-part (1.2) and 3-part (1.2.3) versions
+  const semverRegex = /^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
+
+  const match = semverRegex.exec(cleanVersion);
+  if (!match) {
+    console.warn(`Invalid version format: ${version}`);
+    return null;
+  }
+
+  return {
+    major: parseInt(match[1], 10),
+    minor: parseInt(match[2], 10),
+    patch: match[3] ? parseInt(match[3], 10) : 0, // Default to 0 if patch is missing
+    prerelease: match[4],
+  };
+};
+
+const comparePrereleaseVersions = (current: string, latest: string): number => {
+  const currentParts = current.split(".");
+  const latestParts = latest.split(".");
+  const max = Math.max(currentParts.length, latestParts.length);
+
+  const isNumeric = (s: string) => /^\d+$/.test(s);
+
+  const comparePart = (a?: string, b?: string): number => {
+    if (!a && b) return 1; // latest has more segments → newer
+    if (a && !b) return -1; // current has more segments → older
+    if (!a && !b) return 0;
+
+    const aNum = isNumeric(a!);
+    const bNum = isNumeric(b!);
+    if (aNum && bNum) return parseInt(b!, 10) - parseInt(a!, 10);
+    return b!.localeCompare(a!);
+  };
+
+  for (let i = 0; i < max; i++) {
+    const diff = comparePart(currentParts[i], latestParts[i]);
+    if (diff !== 0) return diff;
+  }
+  return 0;
+};
+
+export const compareVersions = (current: string, latest: string): number => {
+  const currentVersion = parseVersion(current);
+  const latestVersion = parseVersion(latest);
+
+  // If either version is invalid, treat as different
+  if (!currentVersion || !latestVersion) {
+    return current === latest ? 0 : -1;
+  }
+
+  // Compare major.minor.patch
+  const majorDiff = latestVersion.major - currentVersion.major;
+  if (majorDiff !== 0) return majorDiff;
+
+  const minorDiff = latestVersion.minor - currentVersion.minor;
+  if (minorDiff !== 0) return minorDiff;
+
+  const patchDiff = latestVersion.patch - currentVersion.patch;
+  if (patchDiff !== 0) return patchDiff;
+
+  // Handle prerelease versions
+  if (!currentVersion.prerelease && !latestVersion.prerelease) {
+    return 0; // Both are stable, same version
+  }
+
+  if (!currentVersion.prerelease && latestVersion.prerelease) {
+    return -1; // Current is stable, latest is prerelease - current is "newer"
+  }
+
+  if (currentVersion.prerelease && !latestVersion.prerelease) {
+    return 1; // Current is prerelease, latest is stable - latest is newer
+  }
+
+  // Both are prerelease, compare properly
+  return comparePrereleaseVersions(currentVersion.prerelease!, latestVersion.prerelease!);
+};
+
+export const isNewerVersion = (current: string, latest: string): boolean => {
+  return compareVersions(current, latest) > 0;
+};


Test patch:
diff --git a/apps/web/app/(app)/environments/[environmentId]/lib/utils.test.ts b/apps/web/app/(app)/environments/[environmentId]/lib/utils.test.ts
new file mode 100644
index 000000000000..522b8d2bbdd0
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/lib/utils.test.ts
@@ -0,0 +1,82 @@
+// apps/web/lib/utils/version.test.ts
+import { describe, expect, test } from "vitest";
+import { isNewerVersion, parseVersion } from "./utils";
+
+describe("Version utilities", () => {
+  describe("parseVersion", () => {
+    test("should parse valid semantic versions", () => {
+      expect(parseVersion("1.2.3")).toEqual({
+        major: 1,
+        minor: 2,
+        patch: 3,
+      });
+
+      expect(parseVersion("v2.0.0-beta.1")).toEqual({
+        major: 2,
+        minor: 0,
+        patch: 0,
+        prerelease: "beta.1",
+      });
+    });
+
+    test("should return null for invalid versions", () => {
+      expect(parseVersion("invalid")).toBeNull();
+      expect(parseVersion("1.2")).toEqual({
+        major: 1,
+        minor: 2,
+        patch: 0,
+      });
+    });
+  });
+
+  describe("isNewerVersion", () => {
+    test("should correctly identify newer versions", () => {
+      expect(isNewerVersion("1.0.0", "1.0.1")).toBe(true);
+      expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
+      expect(isNewerVersion("1.0.0", "2.0.0")).toBe(true);
+
+      expect(isNewerVersion("1.0.1", "1.0.0")).toBe(false);
+      expect(isNewerVersion("1.0.0", "1.0.0")).toBe(false);
+    });
+
+    test("should handle version prefixes", () => {
+      expect(isNewerVersion("v1.0.0", "v1.0.1")).toBe(true);
+      expect(isNewerVersion("1.0.0", "v1.0.1")).toBe(true);
+    });
+
+    test("should handle prerelease versions", () => {
+      expect(isNewerVersion("1.0.0-beta.1", "1.0.0")).toBe(true);
+      expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
+    });
+
+    test("should correctly compare prerelease versions with numeric parts", () => {
+      // Test the main issue: rc.5 vs rc.10
+      expect(isNewerVersion("3.17.0-rc.5", "3.17.0-rc.10")).toBe(true);
+      expect(isNewerVersion("3.17.0-rc.10", "3.17.0-rc.5")).toBe(false);
+
+      // Test other numeric comparisons
+      expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta.2")).toBe(true);
+      expect(isNewerVersion("1.0.0-alpha.9", "1.0.0-alpha.10")).toBe(true);
+      expect(isNewerVersion("1.0.0-rc.99", "1.0.0-rc.100")).toBe(true);
+
+      // Test mixed alphanumeric comparisons
+      expect(isNewerVersion("1.0.0-alpha.1", "1.0.0-beta.1")).toBe(true);
+      expect(isNewerVersion("1.0.0-beta.1", "1.0.0-rc.1")).toBe(true);
+
+      // Test versions with different number of parts
+      expect(isNewerVersion("1.0.0-beta", "1.0.0-beta.1")).toBe(true);
+      expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta")).toBe(false);
+    });
+
+    test("should treat two-part versions as patch=0 (e.g., 3.16 == 3.16.0)", () => {
+      expect(isNewerVersion("3.16", "3.16.0")).toBe(false);
+      expect(isNewerVersion("3.16.0", "3.16")).toBe(false);
+      expect(isNewerVersion("3.16", "3.16.1")).toBe(true);
+    });
+
+    test("should ignore build metadata for precedence", () => {
+      expect(isNewerVersion("1.0.0+001", "1.0.0+002")).toBe(false);
+      expect(isNewerVersion("1.0.0", "1.0.0+exp.sha.5114f85")).toBe(false);
+    });
+  });
+});