From 7619c7fb94d2b61977eaaddabe7d7ac00cce2f8c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:02:04 -0700 Subject: [PATCH 1/2] Suppress false update-available nags on unstamped and unreleased-channel builds A 0.0.0 build-time fallback compared as older than any release, and a prerelease with no matching dist-tag (rc, alpha, ...) always lost the compare against latest. Both cases now short-circuit to "no update" before hitting the registry, via a shared resolveComparisonChannel / isUpdateAvailable used by both the CLI check and the sidebar card. --- packages/core/api/src/index.ts | 3 + packages/core/api/src/update-check.test.ts | 105 +++++++++++++++++- packages/core/api/src/update-check.ts | 61 +++++++++- .../react/src/components/update-card.test.ts | 58 ++++++++++ packages/react/src/components/update-card.tsx | 44 ++++++-- 5 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 packages/react/src/components/update-card.test.ts diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 6ba0b8b15c..2b7f8ea2e1 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -3,7 +3,10 @@ export { checkForUpdate, resolveDistTags, resolveUpdateChannel, + resolveComparisonChannel, compareVersions, + isUnstampedVersion, + isUpdateAvailable, EXECUTOR_PACKAGE_NAME, type UpdateStatus, type UpdateChannel, diff --git a/packages/core/api/src/update-check.test.ts b/packages/core/api/src/update-check.test.ts index 1ea1ba5a96..c7fc2076bf 100644 --- a/packages/core/api/src/update-check.test.ts +++ b/packages/core/api/src/update-check.test.ts @@ -10,6 +10,9 @@ import { __resetDistTagsCache, checkForUpdate, compareVersions, + isUnstampedVersion, + isUpdateAvailable, + resolveComparisonChannel, resolveDistTags, resolveUpdateChannel, } from "./update-check"; @@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { }); }); +describe("isUnstampedVersion", () => { + it("flags 0.0.0, with or without a prerelease suffix", () => { + expect(isUnstampedVersion("0.0.0")).toBe(true); + expect(isUnstampedVersion("0.0.0-dev")).toBe(true); + }); + + it("leaves any published version alone", () => { + expect(isUnstampedVersion("1.5.22")).toBe(false); + expect(isUnstampedVersion("0.0.1")).toBe(false); + }); + + it("is false for unparseable input", () => { + expect(isUnstampedVersion("not-a-version")).toBe(false); + }); +}); + +describe("resolveComparisonChannel", () => { + it("suppresses unstamped build-time fallback versions", () => { + expect(resolveComparisonChannel("0.0.0")).toBeNull(); + expect(resolveComparisonChannel("0.0.0-dev")).toBeNull(); + }); + + it("suppresses unparseable input", () => { + expect(resolveComparisonChannel("not-a-version")).toBeNull(); + }); + + it("routes a release version to the latest tag", () => { + expect(resolveComparisonChannel("1.6.0")).toBe("latest"); + }); + + it("routes a beta prerelease to the beta tag", () => { + expect(resolveComparisonChannel("1.6.0-beta.1")).toBe("beta"); + }); + + it("suppresses prereleases with no matching dist-tag", () => { + // rc, alpha, next, dev, ... — none of these publish a dist-tag, so + // comparing against `latest` would nag forever. + expect(resolveComparisonChannel("1.6.0-rc.1")).toBeNull(); + expect(resolveComparisonChannel("1.6.0-alpha.1")).toBeNull(); + expect(resolveComparisonChannel("1.6.0-next.1")).toBeNull(); + }); +}); + +describe("isUpdateAvailable", () => { + it("is false with no current version", () => { + expect(isUpdateAvailable(undefined, "1.6.0")).toBe(false); + }); + + it("is false with no comparison channel", () => { + expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false); + expect(isUpdateAvailable("1.6.0-rc.1", "1.6.0")).toBe(false); + }); + + it("is false with no published tag", () => { + expect(isUpdateAvailable("1.5.22", null)).toBe(false); + }); + + it("is false when already current", () => { + expect(isUpdateAvailable("1.6.0", "1.6.0")).toBe(false); + }); + + it("is true when a newer version is published on the matching channel", () => { + expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true); + expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true); + }); +}); + describe("resolveDistTags", () => { it("returns nothing when the check is disabled", async () => { const tags = await resolveDistTags({ @@ -126,6 +196,14 @@ describe("checkForUpdate", () => { expect(status.updateAvailable).toBe(false); }); + it("flags a patch release on the latest channel", async () => { + const status = await checkForUpdate("1.6.0", { + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.1" }) }, + }); + expect(status.updateAvailable).toBe(true); + expect(status.latestVersion).toBe("1.6.1"); + }); + it("compares a beta build against the beta tag", async () => { const status = await checkForUpdate("1.6.0-beta.1", { env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) }, @@ -136,10 +214,33 @@ describe("checkForUpdate", () => { expect(status.command).toBe("npm i -g executor@beta"); }); - it("treats the dev build as upgradeable to any release", async () => { + it("stays quiet on an unstamped dev build", async () => { + // 0.0.0-dev is the build-time fallback, not a real release. It must never + // claim an update is available, and — because there is no dist-tag it + // could legitimately compare against — must not even hit the registry. const status = await checkForUpdate("0.0.0-dev", { env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" }, + fetchImpl: fetchThatFails(), }); - expect(status.updateAvailable).toBe(true); + expect(status.updateAvailable).toBe(false); + expect(status.latestVersion).toBeNull(); + }); + + it("stays quiet on the desktop's unstamped fallback (plain 0.0.0)", async () => { + const status = await checkForUpdate("0.0.0", { + env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" }, + fetchImpl: fetchThatFails(), + }); + expect(status.updateAvailable).toBe(false); + expect(status.latestVersion).toBeNull(); + }); + + it("stays quiet on a prerelease channel with no matching dist-tag", async () => { + const status = await checkForUpdate("1.6.0-rc.1", { + env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) }, + fetchImpl: fetchThatFails(), + }); + expect(status.updateAvailable).toBe(false); + expect(status.latestVersion).toBeNull(); }); }); diff --git a/packages/core/api/src/update-check.ts b/packages/core/api/src/update-check.ts index 310d40decc..330f31b7aa 100644 --- a/packages/core/api/src/update-check.ts +++ b/packages/core/api/src/update-check.ts @@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => { return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease); }; +/** + * True when `version` parses as `0.0.0`, with or without a prerelease suffix + * (e.g. `0.0.0-dev`). `0.0.0` is never a published release — it is the + * build-time fallback baked in when the real version was unavailable at + * build time — so a build carrying it must never claim an update. + */ +export const isUnstampedVersion = (version: string): boolean => { + const parsed = parseVersion(version); + return parsed !== null && parsed.major === 0 && parsed.minor === 0 && parsed.patch === 0; +}; + +/** + * The dist-tag channel `version` can legitimately be compared against, or + * `null` when there is none (the check should be suppressed, not run): + * - an unstamped build (0.0.0*) never shipped, so there is nothing to + * compare it against + * - unparseable input has no meaningful channel + * - no prerelease -> the `latest` tag + * - a prerelease whose first identifier is `beta` -> the `beta` tag + * - any other prerelease (rc, alpha, next, dev, ...) has no matching + * dist-tag, so suppress rather than nag forever against a channel it can + * never catch up to + */ +export const resolveComparisonChannel = (version: string): UpdateChannel | null => { + if (isUnstampedVersion(version)) return null; + const parsed = parseVersion(version); + if (!parsed) return null; + if (parsed.prerelease === null) return "latest"; + return parsed.prerelease[0] === "beta" ? "beta" : null; +}; + +/** + * The single "should we nag?" verdict both the CLI and the web UpdateCard + * read. False whenever there is nothing to compare: no current version, no + * dist-tag channel this version could match (see `resolveComparisonChannel`), + * or no published tag for that channel. + */ +export const isUpdateAvailable = ( + currentVersion: string | undefined, + latestVersion: string | null, +): boolean => { + if (currentVersion === undefined) return false; + if (resolveComparisonChannel(currentVersion) === null) return false; + if (latestVersion === null) return false; + return compareVersions(currentVersion, latestVersion) === -1; +}; + // ── dist-tags resolution ────────────────────────────────────────────────── export type DistTags = Partial>; @@ -223,9 +270,17 @@ export const checkForUpdate = async ( ): Promise => { const channel = resolveUpdateChannel(currentVersion); const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`; + + // No dist-tag can legitimately be compared against this version (unstamped + // dev build, or a prerelease channel that never gets published) — do not + // even hit the registry, since the answer is already known. + const comparisonChannel = resolveComparisonChannel(currentVersion); + if (comparisonChannel === null) { + return { updateAvailable: false, currentVersion, latestVersion: null, channel, command }; + } + const tags = await resolveDistTags(options); - const latestVersion = tags[channel] ?? null; - const updateAvailable = - latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1; + const latestVersion = tags[comparisonChannel] ?? null; + const updateAvailable = isUpdateAvailable(currentVersion, latestVersion); return { updateAvailable, currentVersion, latestVersion, channel, command }; }; diff --git a/packages/react/src/components/update-card.test.ts b/packages/react/src/components/update-card.test.ts new file mode 100644 index 0000000000..b8d823162e --- /dev/null +++ b/packages/react/src/components/update-card.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isUpdateAvailable } from "@executor-js/api"; + +import { updateFetchChannel } from "./update-card"; + +/** + * `updateFetchChannel` is what lets the hook skip the network call for a + * build that can never legitimately claim an update — the dev build's + * unstamped fallback, or a prerelease channel with no published dist-tag. + * Pinning it here is what keeps that decision testable without rendering. + */ +describe("updateFetchChannel", () => { + it("fetches nothing for the unstamped dev build", () => { + expect(updateFetchChannel("0.0.0-dev")).toBeNull(); + }); + + it("fetches nothing for the desktop's plain unstamped fallback", () => { + expect(updateFetchChannel("0.0.0")).toBeNull(); + }); + + it("fetches nothing when there is no version yet", () => { + expect(updateFetchChannel(undefined)).toBeNull(); + }); + + it("fetches the latest tag for a release build", () => { + expect(updateFetchChannel("1.6.0")).toBe("latest"); + }); + + it("fetches the beta tag for a beta prerelease", () => { + expect(updateFetchChannel("1.6.0-beta.2")).toBe("beta"); + }); + + it("fetches nothing for a prerelease channel with no matching dist-tag", () => { + expect(updateFetchChannel("1.6.0-rc.1")).toBeNull(); + }); +}); + +/** + * The card's verdict is the shared `isUpdateAvailable`, so the fetch decision + * above and the "should we show the card?" decision can never disagree. + */ +describe("isUpdateAvailable through the card's fetch channel", () => { + it("stays quiet on the unstamped dev build even if a tag comes back", () => { + expect(updateFetchChannel("0.0.0-dev")).toBeNull(); + expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false); + }); + + it("flags a real update on the latest channel", () => { + expect(updateFetchChannel("1.6.0")).toBe("latest"); + expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true); + }); + + it("flags a real update on the beta channel", () => { + expect(updateFetchChannel("1.6.0-beta.1")).toBe("beta"); + expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true); + }); +}); diff --git a/packages/react/src/components/update-card.tsx b/packages/react/src/components/update-card.tsx index a1570348bb..44179e6c24 100644 --- a/packages/react/src/components/update-card.tsx +++ b/packages/react/src/components/update-card.tsx @@ -13,11 +13,17 @@ // - managed cloud (`"managed"`): nothing, it deploys itself. // // The "is a newer version published?" verdict comes from the same resolver as -// the CLI notice (@executor-js/api) so the two can never disagree. +// the CLI notice (@executor-js/api) — `isUpdateAvailable` — so the two can +// never disagree. import { useCallback, useEffect, useState } from "react"; import { Effect, Exit } from "effect"; -import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api"; +import { + isUpdateAvailable, + resolveComparisonChannel, + resolveUpdateChannel, + type UpdateChannel, +} from "@executor-js/api"; import { Button } from "./button"; import { toast } from "./sonner"; @@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial> = { // ── useLatestVersion ──────────────────────────────────────────────────── +/** + * The dist-tag channel to fetch for `currentVersion`, or `null` when there is + * nothing to check — no version baked in yet, or a build the check should + * stay quiet on (an unstamped `0.0.0*` fallback, or a prerelease channel with + * no published dist-tag; see `resolveComparisonChannel`). Returning `null` + * here is what lets the hook skip the network call entirely, so a dev build + * never even hits `/v1/app/npm/dist-tags`. + */ +export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null { + if (currentVersion === undefined) return null; + return resolveComparisonChannel(currentVersion); +} + function useLatestVersion(currentVersion: string | undefined) { - const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest"; + // The channel shown in the upgrade command differs from the channel + // fetched for comparison: the command always names a real channel, while + // the fetch is skipped entirely for a version with nothing to compare. + const displayChannel: UpdateChannel = currentVersion + ? resolveUpdateChannel(currentVersion) + : "latest"; + const fetchChannel = updateFetchChannel(currentVersion); const [latestVersion, setLatestVersion] = useState(null); useEffect(() => { - if (!currentVersion) return; + if (fetchChannel === null) return; let cancelled = false; void Effect.runPromiseExit( Effect.tryPromise({ @@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { }), ).then((exit) => { if (!cancelled && Exit.isSuccess(exit)) { - setLatestVersion(exit.value?.[channel] ?? null); + setLatestVersion(exit.value?.[fetchChannel] ?? null); } }); return () => { cancelled = true; }; - }, [channel, currentVersion]); + }, [fetchChannel]); - const updateAvailable = - currentVersion !== undefined && - latestVersion !== null && - compareVersions(currentVersion, latestVersion) === -1; + const updateAvailable = isUpdateAvailable(currentVersion, latestVersion); - return { latestVersion, updateAvailable, channel }; + return { latestVersion, updateAvailable, channel: displayChannel }; } // ── Card chrome ────────────────────────────────────────────────────────── From 3cb23da0aa78c9c6b8b77e7117b1b46d3668ec97 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:14:25 -0700 Subject: [PATCH 2/2] Add changeset for update-check false nags fix --- .changeset/update-check-suppress-false-nags.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/update-check-suppress-false-nags.md diff --git a/.changeset/update-check-suppress-false-nags.md b/.changeset/update-check-suppress-false-nags.md new file mode 100644 index 0000000000..2b3011b959 --- /dev/null +++ b/.changeset/update-check-suppress-false-nags.md @@ -0,0 +1,14 @@ +--- +"executor": patch +--- + +**Fix: stop the update check from claiming a newer version is available on builds it cannot compare** + +A build stamped with the placeholder 0.0.0 version always compared as older +than the latest release, and a prerelease on a channel with no matching +dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases +now short-circuit to "no update available" before the check reaches the +registry. + +This applies wherever the update check runs, so the CLI check and the sidebar +update card both stop showing an update prompt that a user could never act on.