History

Fix self-host/Cloudflare update card always showing; show version in dashboard

The self-host and Cloudflare builds baked a placeholder VITE_APP_VERSION (0.0.0-selfhost / 0.0.0-cloudflare), so the update check always compared as "behind" and the card showed even on the latest version. Bake the real release version from the CLI package the same way apps/local does. Also surface the running version in the multiplayer shell sidebar footer so self-hosters can see what they are on. Adds a regression e2e: current build -> no card + version shown.

Rhys Sullivan committed 4 months ago

Commit 81c18801 parent 9586e687 files · +107 −4Browse files

Unified · Split

7 files
  1. .changeset/selfhost-update-card-version.md +7 −0
  2. apps/host-cloudflare/vite.config.ts +11 −1
  3. apps/host-selfhost/vite.config.ts +12 −1
  4. e2e/cloudflare/update-card-render.test.ts +8 −1
  5. e2e/selfhost/update-card-render.test.ts +8 −1
  6. e2e/src/update-card-render.ts +49 −0
  7. packages/react/src/multiplayer/shell.tsx +12 −0
.changeset/selfhost-update-card-version.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"@executor-js/host-selfhost": patch
3+"@executor-js/host-cloudflare": patch
4+"@executor-js/react": patch
5+---
6+
7+Fix the self-host and Cloudflare web dashboards showing "update available" even on the latest version. The builds baked a placeholder version (`0.0.0-selfhost` / `0.0.0-cloudflare`) into the shell, so the update check always compared as behind. They now bake the real release version, and the sidebar footer shows the running version so you can see what you are on.
apps/host-cloudflare/vite.config.ts+11−1
@@ -1,3 +1,4 @@
1+import { readFileSync } from "node:fs";
12 import { fileURLToPath } from "node:url";
23
34 import { defineConfig } from "vite";
@@ -8,6 +9,15 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 4 unmodified lines
89
910 import { routes } from "./tsr.routes";
1011
12+// The real release version (matches the published `executor` dist-tags the
13+// update card compares against). A placeholder here made the card read as
14+// permanently "behind".
15+// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads the version from package.json at build time
16+const cliPackage = JSON.parse(
17+ readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"),
18+) as { version?: string };
19+const EXECUTOR_VERSION = cliPackage.version;
20+
1121 // ---------------------------------------------------------------------------
1222 // Cloudflare web SPA. The SAME shared @executor-js/react shell + pages as cloud
1323 // and self-host; the TanStack router codegen points at THIS app's routes
@@ -36,7 +46,7 @@ export default defineConfig({ · 22 unmodified lines
3646 dedupe: ["react", "react-dom"],
3747 },
3848 define: {
39− "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-cloudflare"),
49+ "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION ?? "0.0.0"),
4050 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,
4151 // so the update card links to the upgrade guide instead of a command.
4252 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"),
apps/host-selfhost/vite.config.ts+12−1
@@ -1,3 +1,4 @@
1+import { readFileSync } from "node:fs";
12 import { Readable } from "node:stream";
23 import { fileURLToPath } from "node:url";
34
@@ -10,6 +11,16 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 6 unmodified lines
1011 import { routes } from "./tsr.routes";
1112 import { stripMcpOrgSegment } from "./src/mcp/org-path";
1213
14+// The real release version (matches the published `executor` dist-tags the
15+// update card compares against), read from the CLI package the same way
16+// apps/local does. A placeholder here made the card read as permanently
17+// "behind", see the update-check comparison in @executor-js/react.
18+// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads the version from package.json at build time
19+const cliPackage = JSON.parse(
20+ readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"),
21+) as { version?: string };
22+const EXECUTOR_VERSION = cliPackage.version;
23+
1324 // Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points
1425 // the TanStack router codegen at THIS app's routes (web/routes) so we get the
1526 // multiplayer shell + Better-Auth gate (routes/__root.tsx) instead of the
@@ -140,7 +151,7 @@ export default defineConfig({ · 124 unmodified lines
140151 dedupe: ["react", "react-dom"],
141152 },
142153 define: {
143− "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-selfhost"),
154+ "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION ?? "0.0.0"),
144155 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not
145156 // npm, so the update card links to the upgrade guide instead of a command.
146157 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),
e2e/cloudflare/update-card-render.test.ts+8−1
@@ -1,8 +1,15 @@
11 // Cloudflare-only: the update card paints on the Cloudflare-served web shell
22 // (Workers Static Assets + the Worker route). See ../src/update-card-render.ts
33 // for the shared body.
4−import { registerUpdateCardRenderScenario } from "../src/update-card-render";
4+import {
5+ registerUpdateCardCurrentScenario,
6+ registerUpdateCardRenderScenario,
7+} from "../src/update-card-render";
58
69 registerUpdateCardRenderScenario(
710 "Cloudflare · the web shell sidebar surfaces the update-available card",
811 );
12+
13+registerUpdateCardCurrentScenario(
14+ "Cloudflare · no update card when current, and the footer shows the version",
15+);
e2e/selfhost/update-card-render.test.ts+8−1
@@ -1,8 +1,15 @@
11 // Selfhost-only (runs on the dev server AND the production Docker image): the
22 // update card paints on the self-host web shell. See
33 // ../src/update-card-render.ts for the shared body.
4−import { registerUpdateCardRenderScenario } from "../src/update-card-render";
4+import {
5+ registerUpdateCardCurrentScenario,
6+ registerUpdateCardRenderScenario,
7+} from "../src/update-card-render";
58
69 registerUpdateCardRenderScenario(
710 "Selfhost · the web shell sidebar surfaces the update-available card",
811 );
12+
13+registerUpdateCardCurrentScenario(
14+ "Selfhost · no update card when current, and the footer shows the version",
15+);
e2e/src/update-card-render.ts+49−0
@@ -56,3 +56,52 @@ export const registerUpdateCardRenderScenario = (name: string): void => · 55 unmodified lines
5656 });
5757 }),
5858 );
59+
60+// Regression for the "card always shows on Docker even on the latest version"
61+// report: a placeholder build version (0.0.0-selfhost / 0.0.0-cloudflare) made
62+// the comparison permanently "behind". With the real version baked in, a
63+// published version that is NOT newer must leave the card hidden, and the
64+// sidebar footer must show the running version (the other half of the report).
65+export const registerUpdateCardCurrentScenario = (name: string): void =>
66+ scenario(
67+ name,
68+ { timeout: 120_000 },
69+ Effect.gen(function* () {
70+ const target = yield* Target;
71+ const browser = yield* Browser;
72+ const identity = yield* target.newIdentity();
73+
74+ yield* browser.session(identity, async ({ page, step }) => {
75+ // Report a published version OLDER than this build, i.e. the build is
76+ // current. With the placeholder version this still showed the card.
77+ await page.route("**/v1/app/npm/dist-tags", (route) =>
78+ route.fulfill({
79+ contentType: "application/json",
80+ body: JSON.stringify({ latest: "0.0.1", beta: "0.0.1-beta.1" }),
81+ }),
82+ );
83+
84+ await step("Open the console", async () => {
85+ await page.goto("/", { waitUntil: "networkidle" });
86+ await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 });
87+ });
88+
89+ await step("No update card, and the footer shows the running version", async () => {
90+ // The footer version proves the build injects a real semver, not a
91+ // placeholder, which is what makes the comparison correct.
92+ const version = page
93+ .locator("aside")
94+ .getByText(/^v\d+\.\d+\.\d+/)
95+ .first();
96+ await version.waitFor({ timeout: 10_000 });
97+ // The mocked update check resolves on mount; give the state a beat to
98+ // settle, then assert the card stayed hidden.
99+ await page.waitForTimeout(750);
100+ expect(
101+ await page.getByText("Update available").count(),
102+ "no update card when the build is current",
103+ ).toBe(0);
104+ });
105+ });
106+ }),
107+ );
packages/react/src/multiplayer/shell.tsx+12−0
@@ -59,6 +59,13 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 58 unmodified lines
5959 * via {@link ShellProps.docsUrl}. */
6060 export const DEFAULT_DOCS_URL = "https://executor.sh/docs";
6161
62+// The build version, surfaced in the sidebar footer so self-hosters can see
63+// what they're running (and sanity-check the update card). Undefined on builds
64+// that don't inject it (e.g. cloud) — then the line is hidden.
65+const APP_VERSION = (
66+ import.meta as ImportMeta & { readonly env?: { readonly VITE_APP_VERSION?: string } }
67+).env?.VITE_APP_VERSION;
68+
6269 // Scope-relative path -> the route id under the optional org segment
6370 // ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely
6471 // typed on purpose: host-specific items ("/admin", "/billing") resolve against
@@ -367,6 +374,11 @@ function SidebarContent( · 302 unmodified lines
367374
368375 <div className="shrink-0 border-t border-sidebar-border p-2">
369376 <DocsLink href={props.docsUrl ?? DEFAULT_DOCS_URL} onNavigate={props.onNavigate} />
377+ {APP_VERSION && (
378+ <p className="px-2.5 pt-1.5 font-mono text-[11px] text-muted-foreground tabular-nums">
379+ v{APP_VERSION}
380+ </p>
381+ )}
370382 </div>
371383
372384 {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>}