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.
Unified · Split
| @@ -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. | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | ||
| 1 | import { fileURLToPath } from "node:url"; | 2 | import { fileURLToPath } from "node:url"; |
| 2 | 3 | ||
| 3 | import { defineConfig } from "vite"; | 4 | import { defineConfig } from "vite"; |
| @@ -8,6 +9,15 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 4 unmodified lines | |||
| 8 | 9 | ||
| 9 | import { routes } from "./tsr.routes"; | 10 | import { 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 | ||
| 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 | |||
| 11 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| 12 | // Cloudflare web SPA. The SAME shared @executor-js/react shell + pages as cloud | 22 | // 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 routes | 23 | // 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"), |
| @@ -1,3 +1,4 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | ||
| 1 | import { Readable } from "node:stream"; | 2 | import { Readable } from "node:stream"; |
| 2 | import { fileURLToPath } from "node:url"; | 3 | import { fileURLToPath } from "node:url"; |
| 3 | 4 | ||
| @@ -10,6 +11,16 @@ import executorVitePlugin from "@executor-js/vite-plugin"; · 6 unmodified lines | |||
| 10 | import { routes } from "./tsr.routes"; | 11 | import { routes } from "./tsr.routes"; |
| 11 | import { stripMcpOrgSegment } from "./src/mcp/org-path"; | 12 | import { 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 | ||
| 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 | |||
| 13 | // Self-host web SPA. Mirrors @executor-js/app's vite plugin bundle, but points | 24 | // 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 the | 25 | // 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 the | 26 | // 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), not | 155 | // 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"), |
| @@ -1,8 +1,15 @@ | |||
| 1 | // Cloudflare-only: the update card paints on the Cloudflare-served web shell | 1 | // Cloudflare-only: the update card paints on the Cloudflare-served web shell |
| 2 | // (Workers Static Assets + the Worker route). See ../src/update-card-render.ts | 2 | // (Workers Static Assets + the Worker route). See ../src/update-card-render.ts |
| 3 | // for the shared body. | 3 | // for the shared body. |
| 4 | import { registerUpdateCardRenderScenario } from "../src/update-card-render"; | 4 | import { |
| 5 | registerUpdateCardCurrentScenario, | ||
| 6 | registerUpdateCardRenderScenario, | ||
| 7 | } from "../src/update-card-render"; | ||
| 5 | 8 | ||
| 6 | registerUpdateCardRenderScenario( | 9 | registerUpdateCardRenderScenario( |
| 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 | |||
| 13 | registerUpdateCardCurrentScenario( | ||
| 14 | "Cloudflare · no update card when current, and the footer shows the version", | ||
| 15 | ); | ||
| @@ -1,8 +1,15 @@ | |||
| 1 | // Selfhost-only (runs on the dev server AND the production Docker image): the | 1 | // Selfhost-only (runs on the dev server AND the production Docker image): the |
| 2 | // update card paints on the self-host web shell. See | 2 | // 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. |
| 4 | import { registerUpdateCardRenderScenario } from "../src/update-card-render"; | 4 | import { |
| 5 | registerUpdateCardCurrentScenario, | ||
| 6 | registerUpdateCardRenderScenario, | ||
| 7 | } from "../src/update-card-render"; | ||
| 5 | 8 | ||
| 6 | registerUpdateCardRenderScenario( | 9 | registerUpdateCardRenderScenario( |
| 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 | |||
| 13 | registerUpdateCardCurrentScenario( | ||
| 14 | "Selfhost · no update card when current, and the footer shows the version", | ||
| 15 | ); | ||
| @@ -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). | ||
| 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 | ); | ||
| @@ -59,6 +59,13 @@ export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ · 58 unmodified lines | |||
| 59 | * via {@link ShellProps.docsUrl}. */ | 59 | * via {@link ShellProps.docsUrl}. */ |
| 60 | export const DEFAULT_DOCS_URL = "https://executor.sh/docs"; | 60 | export 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. | ||
| 65 | const 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 segment | 69 | // Scope-relative path -> the route id under the optional org segment |
| 63 | // ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely | 70 | // ("/" -> "/{-$orgSlug}", "/policies" -> "/{-$orgSlug}/policies"). Loosely |
| 64 | // typed on purpose: host-specific items ("/admin", "/billing") resolve against | 71 | // 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>} |