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
7Fix 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 @@
1import { readFileSync } from "node:fs";
1import { fileURLToPath } from "node:url";2import { fileURLToPath } from "node:url";
2 3
3import { defineConfig } from "vite";4import { defineConfig } from "vite";
@@ -8,6 +9,15 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 4 unmodified lines
8 9
9import { routes } from "./tsr.routes";10import { routes } from "./tsr.routes";
10 11
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
16const cliPackage = JSON.parse(
17 readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"),
18) as { version?: string };
19const EXECUTOR_VERSION = cliPackage.version;
20
11// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
12// Cloudflare web SPA. The SAME shared @executor-js/react shell + pages as cloud22// Cloudflare web SPA. The SAME shared @executor-js/react shell + pages as cloud
13// and self-host; the TanStack router codegen points at THIS app's routes23// and self-host; the TanStack router codegen points at THIS app's routes
@@ -36,7 +46,7 @@ export default defineConfig({ · 22 unmodified lines
36 dedupe: ["react", "react-dom"],46 dedupe: ["react", "react-dom"],
37 },47 },
38 define: {48 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"),
40 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,50 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,
41 // so the update card links to the upgrade guide instead of a command.51 // so the update card links to the upgrade guide instead of a command.
42 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"),52 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"),
apps/host-selfhost/vite.config.ts+12−1
@@ -1,3 +1,4 @@
1import { readFileSync } from "node:fs";
1import { Readable } from "node:stream";2import { Readable } from "node:stream";
2import { fileURLToPath } from "node:url";3import { fileURLToPath } from "node:url";
3 4
@@ -10,6 +11,16 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 6 unmodified lines
10import { routes } from "./tsr.routes";11import { routes } from "./tsr.routes";
11import { stripMcpOrgSegment } from "./src/mcp/org-path";12import { stripMcpOrgSegment } from "./src/mcp/org-path";
12 13
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
19const cliPackage = JSON.parse(
20 readFileSync(new URL("../cli/package.json", import.meta.url), "utf8"),
21) as { version?: string };
22const EXECUTOR_VERSION = cliPackage.version;
23
13// Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points24// Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points
14// the TanStack router codegen at THIS app's routes (web/routes) so we get the25// the TanStack router codegen at THIS app's routes (web/routes) so we get the
15// multiplayer shell + Better-Auth gate (routes/__root.tsx) instead of the26// multiplayer shell + Better-Auth gate (routes/__root.tsx) instead of the
@@ -140,7 +151,7 @@ export default defineConfig({ · 124 unmodified lines
140 dedupe: ["react", "react-dom"],151 dedupe: ["react", "react-dom"],
141 },152 },
142 define: {153 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"),
144 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not155 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not
145 // npm, so the update card links to the upgrade guide instead of a command.156 // npm, so the update card links to the upgrade guide instead of a command.
146 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),157 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),
e2e/cloudflare/update-card-render.test.ts+8−1
@@ -1,8 +1,15 @@
1// Cloudflare-only: the update card paints on the Cloudflare-served web shell1// Cloudflare-only: the update card paints on the Cloudflare-served web shell
2// (Workers Static Assets + the Worker route). See ../src/update-card-render.ts2// (Workers Static Assets + the Worker route). See ../src/update-card-render.ts
3// for the shared body.3// for the shared body.
4import { registerUpdateCardRenderScenario } from "../src/update-card-render";4import {
5 registerUpdateCardCurrentScenario,
6 registerUpdateCardRenderScenario,
7} from "../src/update-card-render";
5 8
6registerUpdateCardRenderScenario(9registerUpdateCardRenderScenario(
7 "Cloudflare · the web shell sidebar surfaces the update-available card",10 "Cloudflare · the web shell sidebar surfaces the update-available card",
8);11);
12
13registerUpdateCardCurrentScenario(
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 @@
1// Selfhost-only (runs on the dev server AND the production Docker image): the1// Selfhost-only (runs on the dev server AND the production Docker image): the
2// update card paints on the self-host web shell. See2// update card paints on the self-host web shell. See
3// ../src/update-card-render.ts for the shared body.3// ../src/update-card-render.ts for the shared body.
4import { registerUpdateCardRenderScenario } from "../src/update-card-render";4import {
5 registerUpdateCardCurrentScenario,
6 registerUpdateCardRenderScenario,
7} from "../src/update-card-render";
5 8
6registerUpdateCardRenderScenario(9registerUpdateCardRenderScenario(
7 "Selfhost · the web shell sidebar surfaces the update-available card",10 "Selfhost · the web shell sidebar surfaces the update-available card",
8);11);
12
13registerUpdateCardCurrentScenario(
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
56 });56 });
57 }),57 }),
58 );58 );
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).
65export 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
59 * via {@link ShellProps.docsUrl}. */59 * via {@link ShellProps.docsUrl}. */
60export const DEFAULT_DOCS_URL = "https://executor.sh/docs";60export const DEFAULT_DOCS_URL = "https://executor.sh/docs";
61 61
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.
65const APP_VERSION = (
66 import.meta as ImportMeta & { readonly env?: { readonly VITE_APP_VERSION?: string } }
67).env?.VITE_APP_VERSION;
68
62// Scope-relative path -> the route id under the optional org segment69// Scope-relative path -> the route id under the optional org segment
63// ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely70// ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely
64// typed on purpose: host-specific items ("/admin", "/billing") resolve against71// typed on purpose: host-specific items ("/admin", "/billing") resolve against
@@ -367,6 +374,11 @@ function SidebarContent( · 302 unmodified lines
367 374
368 <div className="shrink-0 border-t border-sidebar-border p-2">375 <div className="shrink-0 border-t border-sidebar-border p-2">
369 <DocsLink href={props.docsUrl ?? DEFAULT_DOCS_URL} onNavigate={props.onNavigate} />376 <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 )}
370 </div>382 </div>
371 383
372 {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>}384 {props.supportSlot && <div className="shrink-0 px-2 pb-2">{props.supportSlot}</div>}