History

Fix false update-available nags (#1819)

* 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. * Add changeset for update-check false nags fix

Rhys Sullivan committed 2 months ago

Commit 1cd81d61 parent 6305b6d6 files · +269 −16Browse files

Unified · Split

6 files
  1. .changeset/update-check-suppress-false-nags.md +14 −0
  2. packages/core/api/src/index.ts +3 −0
  3. packages/core/api/src/update-check.test.ts +103 −2
  4. packages/core/api/src/update-check.ts +58 −3
  5. packages/react/src/components/update-card.test.ts +58 −0
  6. packages/react/src/components/update-card.tsx +33 −11
.changeset/update-check-suppress-false-nags.md+14−0
@@ -0,0 +1,14 @@
1+---
2+"executor": patch
3+---
4+
5+**Fix: stop the update check from claiming a newer version is available on builds it cannot compare**
6+
7+A build stamped with the placeholder 0.0.0 version always compared as older
8+than the latest release, and a prerelease on a channel with no matching
9+dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases
10+now short-circuit to "no update available" before the check reaches the
11+registry.
12+
13+This applies wherever the update check runs, so the CLI check and the sidebar
14+update card both stop showing an update prompt that a user could never act on.
packages/core/api/src/index.ts+3−0
@@ -3,7 +3,10 @@ export { · 2 unmodified lines
33 checkForUpdate,
44 resolveDistTags,
55 resolveUpdateChannel,
6+ resolveComparisonChannel,
67 compareVersions,
8+ isUnstampedVersion,
9+ isUpdateAvailable,
710 EXECUTOR_PACKAGE_NAME,
811 type UpdateStatus,
912 type UpdateChannel,
packages/core/api/src/update-check.test.ts+103−2
@@ -10,6 +10,9 @@ import { · 9 unmodified lines
1010 __resetDistTagsCache,
1111 checkForUpdate,
1212 compareVersions,
13+ isUnstampedVersion,
14+ isUpdateAvailable,
15+ resolveComparisonChannel,
1316 resolveDistTags,
1417 resolveUpdateChannel,
1518 } from "./update-check";
@@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { · 37 unmodified lines
5356 });
5457 });
5558
59+describe("isUnstampedVersion", () => {
60+ it("flags 0.0.0, with or without a prerelease suffix", () => {
61+ expect(isUnstampedVersion("0.0.0")).toBe(true);
62+ expect(isUnstampedVersion("0.0.0-dev")).toBe(true);
63+ });
64+
65+ it("leaves any published version alone", () => {
66+ expect(isUnstampedVersion("1.5.22")).toBe(false);
67+ expect(isUnstampedVersion("0.0.1")).toBe(false);
68+ });
69+
70+ it("is false for unparseable input", () => {
71+ expect(isUnstampedVersion("not-a-version")).toBe(false);
72+ });
73+});
74+
75+describe("resolveComparisonChannel", () => {
76+ it("suppresses unstamped build-time fallback versions", () => {
77+ expect(resolveComparisonChannel("0.0.0")).toBeNull();
78+ expect(resolveComparisonChannel("0.0.0-dev")).toBeNull();
79+ });
80+
81+ it("suppresses unparseable input", () => {
82+ expect(resolveComparisonChannel("not-a-version")).toBeNull();
83+ });
84+
85+ it("routes a release version to the latest tag", () => {
86+ expect(resolveComparisonChannel("1.6.0")).toBe("latest");
87+ });
88+
89+ it("routes a beta prerelease to the beta tag", () => {
90+ expect(resolveComparisonChannel("1.6.0-beta.1")).toBe("beta");
91+ });
92+
93+ it("suppresses prereleases with no matching dist-tag", () => {
94+ // rc, alpha, next, dev, ... — none of these publish a dist-tag, so
95+ // comparing against `latest` would nag forever.
96+ expect(resolveComparisonChannel("1.6.0-rc.1")).toBeNull();
97+ expect(resolveComparisonChannel("1.6.0-alpha.1")).toBeNull();
98+ expect(resolveComparisonChannel("1.6.0-next.1")).toBeNull();
99+ });
100+});
101+
102+describe("isUpdateAvailable", () => {
103+ it("is false with no current version", () => {
104+ expect(isUpdateAvailable(undefined, "1.6.0")).toBe(false);
105+ });
106+
107+ it("is false with no comparison channel", () => {
108+ expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
109+ expect(isUpdateAvailable("1.6.0-rc.1", "1.6.0")).toBe(false);
110+ });
111+
112+ it("is false with no published tag", () => {
113+ expect(isUpdateAvailable("1.5.22", null)).toBe(false);
114+ });
115+
116+ it("is false when already current", () => {
117+ expect(isUpdateAvailable("1.6.0", "1.6.0")).toBe(false);
118+ });
119+
120+ it("is true when a newer version is published on the matching channel", () => {
121+ expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
122+ expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
123+ });
124+});
125+
56126 describe("resolveDistTags", () => {
57127 it("returns nothing when the check is disabled", async () => {
58128 const tags = await resolveDistTags({
@@ -126,6 +196,14 @@ describe("checkForUpdate", () => { · 67 unmodified lines
126196 expect(status.updateAvailable).toBe(false);
127197 });
128198
199+ it("flags a patch release on the latest channel", async () => {
200+ const status = await checkForUpdate("1.6.0", {
201+ env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.1" }) },
202+ });
203+ expect(status.updateAvailable).toBe(true);
204+ expect(status.latestVersion).toBe("1.6.1");
205+ });
206+
129207 it("compares a beta build against the beta tag", async () => {
130208 const status = await checkForUpdate("1.6.0-beta.1", {
131209 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) },
@@ -136,10 +214,33 @@ describe("checkForUpdate", () => { · 4 unmodified lines
136214 expect(status.command).toBe("npm i -g executor@beta");
137215 });
138216
139− it("treats the dev build as upgradeable to any release", async () => {
217+ it("stays quiet on an unstamped dev build", async () => {
218+ // 0.0.0-dev is the build-time fallback, not a real release. It must never
219+ // claim an update is available, and — because there is no dist-tag it
220+ // could legitimately compare against — must not even hit the registry.
140221 const status = await checkForUpdate("0.0.0-dev", {
141222 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
223+ fetchImpl: fetchThatFails(),
142224 });
143− expect(status.updateAvailable).toBe(true);
225+ expect(status.updateAvailable).toBe(false);
226+ expect(status.latestVersion).toBeNull();
227+ });
228+
229+ it("stays quiet on the desktop's unstamped fallback (plain 0.0.0)", async () => {
230+ const status = await checkForUpdate("0.0.0", {
231+ env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
232+ fetchImpl: fetchThatFails(),
233+ });
234+ expect(status.updateAvailable).toBe(false);
235+ expect(status.latestVersion).toBeNull();
236+ });
237+
238+ it("stays quiet on a prerelease channel with no matching dist-tag", async () => {
239+ const status = await checkForUpdate("1.6.0-rc.1", {
240+ env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) },
241+ fetchImpl: fetchThatFails(),
242+ });
243+ expect(status.updateAvailable).toBe(false);
244+ expect(status.latestVersion).toBeNull();
144245 });
145246 });
packages/core/api/src/update-check.ts+58−3
@@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => { · 101 unmodified lines
102102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);
103103 };
104104
105+/**
106+ * True when `version` parses as `0.0.0`, with or without a prerelease suffix
107+ * (e.g. `0.0.0-dev`). `0.0.0` is never a published release — it is the
108+ * build-time fallback baked in when the real version was unavailable at
109+ * build time — so a build carrying it must never claim an update.
110+ */
111+export const isUnstampedVersion = (version: string): boolean => {
112+ const parsed = parseVersion(version);
113+ return parsed !== null && parsed.major === 0 && parsed.minor === 0 && parsed.patch === 0;
114+};
115+
116+/**
117+ * The dist-tag channel `version` can legitimately be compared against, or
118+ * `null` when there is none (the check should be suppressed, not run):
119+ * - an unstamped build (0.0.0*) never shipped, so there is nothing to
120+ * compare it against
121+ * - unparseable input has no meaningful channel
122+ * - no prerelease -> the `latest` tag
123+ * - a prerelease whose first identifier is `beta` -> the `beta` tag
124+ * - any other prerelease (rc, alpha, next, dev, ...) has no matching
125+ * dist-tag, so suppress rather than nag forever against a channel it can
126+ * never catch up to
127+ */
128+export const resolveComparisonChannel = (version: string): UpdateChannel | null => {
129+ if (isUnstampedVersion(version)) return null;
130+ const parsed = parseVersion(version);
131+ if (!parsed) return null;
132+ if (parsed.prerelease === null) return "latest";
133+ return parsed.prerelease[0] === "beta" ? "beta" : null;
134+};
135+
136+/**
137+ * The single "should we nag?" verdict both the CLI and the web UpdateCard
138+ * read. False whenever there is nothing to compare: no current version, no
139+ * dist-tag channel this version could match (see `resolveComparisonChannel`),
140+ * or no published tag for that channel.
141+ */
142+export const isUpdateAvailable = (
143+ currentVersion: string | undefined,
144+ latestVersion: string | null,
145+): boolean => {
146+ if (currentVersion === undefined) return false;
147+ if (resolveComparisonChannel(currentVersion) === null) return false;
148+ if (latestVersion === null) return false;
149+ return compareVersions(currentVersion, latestVersion) === -1;
150+};
151+
105152 // ── dist-tags resolution ──────────────────────────────────────────────────
106153
107154 export type DistTags = Partial<Record<UpdateChannel, string>>;
@@ -223,9 +270,17 @@ export const checkForUpdate = async ( · 115 unmodified lines
223270 ): Promise<UpdateStatus> => {
224271 const channel = resolveUpdateChannel(currentVersion);
225272 const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;
273+
274+ // No dist-tag can legitimately be compared against this version (unstamped
275+ // dev build, or a prerelease channel that never gets published) — do not
276+ // even hit the registry, since the answer is already known.
277+ const comparisonChannel = resolveComparisonChannel(currentVersion);
278+ if (comparisonChannel === null) {
279+ return { updateAvailable: false, currentVersion, latestVersion: null, channel, command };
280+ }
281+
226282 const tags = await resolveDistTags(options);
227− const latestVersion = tags[channel] ?? null;
228− const updateAvailable =
229− latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1;
283+ const latestVersion = tags[comparisonChannel] ?? null;
284+ const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
230285 return { updateAvailable, currentVersion, latestVersion, channel, command };
231286 };
packages/react/src/components/update-card.test.ts+58−0
@@ -0,0 +1,58 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { isUpdateAvailable } from "@executor-js/api";
4+
5+import { updateFetchChannel } from "./update-card";
6+
7+/**
8+ * `updateFetchChannel` is what lets the hook skip the network call for a
9+ * build that can never legitimately claim an update — the dev build's
10+ * unstamped fallback, or a prerelease channel with no published dist-tag.
11+ * Pinning it here is what keeps that decision testable without rendering.
12+ */
13+describe("updateFetchChannel", () => {
14+ it("fetches nothing for the unstamped dev build", () => {
15+ expect(updateFetchChannel("0.0.0-dev")).toBeNull();
16+ });
17+
18+ it("fetches nothing for the desktop's plain unstamped fallback", () => {
19+ expect(updateFetchChannel("0.0.0")).toBeNull();
20+ });
21+
22+ it("fetches nothing when there is no version yet", () => {
23+ expect(updateFetchChannel(undefined)).toBeNull();
24+ });
25+
26+ it("fetches the latest tag for a release build", () => {
27+ expect(updateFetchChannel("1.6.0")).toBe("latest");
28+ });
29+
30+ it("fetches the beta tag for a beta prerelease", () => {
31+ expect(updateFetchChannel("1.6.0-beta.2")).toBe("beta");
32+ });
33+
34+ it("fetches nothing for a prerelease channel with no matching dist-tag", () => {
35+ expect(updateFetchChannel("1.6.0-rc.1")).toBeNull();
36+ });
37+});
38+
39+/**
40+ * The card's verdict is the shared `isUpdateAvailable`, so the fetch decision
41+ * above and the "should we show the card?" decision can never disagree.
42+ */
43+describe("isUpdateAvailable through the card's fetch channel", () => {
44+ it("stays quiet on the unstamped dev build even if a tag comes back", () => {
45+ expect(updateFetchChannel("0.0.0-dev")).toBeNull();
46+ expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
47+ });
48+
49+ it("flags a real update on the latest channel", () => {
50+ expect(updateFetchChannel("1.6.0")).toBe("latest");
51+ expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
52+ });
53+
54+ it("flags a real update on the beta channel", () => {
55+ expect(updateFetchChannel("1.6.0-beta.1")).toBe("beta");
56+ expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
57+ });
58+});
packages/react/src/components/update-card.tsx+33−11
@@ -13,11 +13,17 @@ · 12 unmodified lines
1313 // - managed cloud (`"managed"`): nothing, it deploys itself.
1414 //
1515 // The "is a newer version published?" verdict comes from the same resolver as
16−// the CLI notice (@executor-js/api) so the two can never disagree.
16+// the CLI notice (@executor-js/api) — `isUpdateAvailable` — so the two can
17+// never disagree.
1718 import { useCallback, useEffect, useState } from "react";
1819
1920 import { Effect, Exit } from "effect";
20−import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api";
21+import {
22+ isUpdateAvailable,
23+ resolveComparisonChannel,
24+ resolveUpdateChannel,
25+ type UpdateChannel,
26+} from "@executor-js/api";
2127
2228 import { Button } from "./button";
2329 import { toast } from "./sonner";
@@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = { · 23 unmodified lines
4753
4854 // ── useLatestVersion ────────────────────────────────────────────────────
4955
56+/**
57+ * The dist-tag channel to fetch for `currentVersion`, or `null` when there is
58+ * nothing to check — no version baked in yet, or a build the check should
59+ * stay quiet on (an unstamped `0.0.0*` fallback, or a prerelease channel with
60+ * no published dist-tag; see `resolveComparisonChannel`). Returning `null`
61+ * here is what lets the hook skip the network call entirely, so a dev build
62+ * never even hits `/v1/app/npm/dist-tags`.
63+ */
64+export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null {
65+ if (currentVersion === undefined) return null;
66+ return resolveComparisonChannel(currentVersion);
67+}
68+
5069 function useLatestVersion(currentVersion: string | undefined) {
51− const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest";
70+ // The channel shown in the upgrade command differs from the channel
71+ // fetched for comparison: the command always names a real channel, while
72+ // the fetch is skipped entirely for a version with nothing to compare.
73+ const displayChannel: UpdateChannel = currentVersion
74+ ? resolveUpdateChannel(currentVersion)
75+ : "latest";
76+ const fetchChannel = updateFetchChannel(currentVersion);
5277 const [latestVersion, setLatestVersion] = useState<string | null>(null);
5378
5479 useEffect(() => {
55− if (!currentVersion) return;
80+ if (fetchChannel === null) return;
5681 let cancelled = false;
5782 void Effect.runPromiseExit(
5883 Effect.tryPromise({
@@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { · 6 unmodified lines
6590 }),
6691 ).then((exit) => {
6792 if (!cancelled && Exit.isSuccess(exit)) {
68− setLatestVersion(exit.value?.[channel] ?? null);
93+ setLatestVersion(exit.value?.[fetchChannel] ?? null);
6994 }
7095 });
7196 return () => {
7297 cancelled = true;
7398 };
74− }, [channel, currentVersion]);
99+ }, [fetchChannel]);
75100
76− const updateAvailable =
77− currentVersion !== undefined &&
78− latestVersion !== null &&
79− compareVersions(currentVersion, latestVersion) === -1;
101+ const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
80102
81− return { latestVersion, updateAvailable, channel };
103+ return { latestVersion, updateAvailable, channel: displayChannel };
82104 }
83105
84106 // ── Card chrome ──────────────────────────────────────────────────────────