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
7A build stamped with the placeholder 0.0.0 version always compared as older
8than the latest release, and a prerelease on a channel with no matching
9dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases
10now short-circuit to "no update available" before the check reaches the
11registry.
12
13This applies wherever the update check runs, so the CLI check and the sidebar
14update 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
3 checkForUpdate,3 checkForUpdate,
4 resolveDistTags,4 resolveDistTags,
5 resolveUpdateChannel,5 resolveUpdateChannel,
6 resolveComparisonChannel,
6 compareVersions,7 compareVersions,
8 isUnstampedVersion,
9 isUpdateAvailable,
7 EXECUTOR_PACKAGE_NAME,10 EXECUTOR_PACKAGE_NAME,
8 type UpdateStatus,11 type UpdateStatus,
9 type UpdateChannel,12 type UpdateChannel,
packages/core/api/src/update-check.test.ts+103−2
@@ -10,6 +10,9 @@ import { · 9 unmodified lines
10 __resetDistTagsCache,10 __resetDistTagsCache,
11 checkForUpdate,11 checkForUpdate,
12 compareVersions,12 compareVersions,
13 isUnstampedVersion,
14 isUpdateAvailable,
15 resolveComparisonChannel,
13 resolveDistTags,16 resolveDistTags,
14 resolveUpdateChannel,17 resolveUpdateChannel,
15} from "./update-check";18} from "./update-check";
@@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { · 37 unmodified lines
53 });56 });
54});57});
55 58
59describe("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
75describe("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
102describe("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
56describe("resolveDistTags", () => {126describe("resolveDistTags", () => {
57 it("returns nothing when the check is disabled", async () => {127 it("returns nothing when the check is disabled", async () => {
58 const tags = await resolveDistTags({128 const tags = await resolveDistTags({
@@ -126,6 +196,14 @@ describe("checkForUpdate", () => { · 67 unmodified lines
126 expect(status.updateAvailable).toBe(false);196 expect(status.updateAvailable).toBe(false);
127 });197 });
128 198
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
129 it("compares a beta build against the beta tag", async () => {207 it("compares a beta build against the beta tag", async () => {
130 const status = await checkForUpdate("1.6.0-beta.1", {208 const status = await checkForUpdate("1.6.0-beta.1", {
131 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) },209 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
136 expect(status.command).toBe("npm i -g executor@beta");214 expect(status.command).toBe("npm i -g executor@beta");
137 });215 });
138 216
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.
140 const status = await checkForUpdate("0.0.0-dev", {221 const status = await checkForUpdate("0.0.0-dev", {
141 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },222 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
223 fetchImpl: fetchThatFails(),
142 });224 });
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();
144 });245 });
145});246});
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
102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);
103};103};
104 104
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 */
111export 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 */
128export 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 */
142export 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
105// ── dist-tags resolution ──────────────────────────────────────────────────152// ── dist-tags resolution ──────────────────────────────────────────────────
106 153
107export type DistTags = Partial<Record<UpdateChannel, string>>;154export type DistTags = Partial<Record<UpdateChannel, string>>;
@@ -223,9 +270,17 @@ export const checkForUpdate = async ( · 115 unmodified lines
223): Promise<UpdateStatus> => {270): Promise<UpdateStatus> => {
224 const channel = resolveUpdateChannel(currentVersion);271 const channel = resolveUpdateChannel(currentVersion);
225 const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;272 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
226 const tags = await resolveDistTags(options);282 const tags = await resolveDistTags(options);
227 const latestVersion = tags[channel] ?? null;283 const latestVersion = tags[comparisonChannel] ?? null;
228 const updateAvailable =284 const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
229 latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1;
230 return { updateAvailable, currentVersion, latestVersion, channel, command };285 return { updateAvailable, currentVersion, latestVersion, channel, command };
231};286};
packages/react/src/components/update-card.test.ts+58−0
@@ -0,0 +1,58 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { isUpdateAvailable } from "@executor-js/api";
4
5import { 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 */
13describe("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 */
43describe("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
13// - managed cloud (`"managed"`): nothing, it deploys itself.13// - managed cloud (`"managed"`): nothing, it deploys itself.
14//14//
15// The "is a newer version published?" verdict comes from the same resolver as15// 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.
17import { useCallback, useEffect, useState } from "react";18import { useCallback, useEffect, useState } from "react";
18 19
19import { Effect, Exit } from "effect";20import { Effect, Exit } from "effect";
20import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api";21import {
22 isUpdateAvailable,
23 resolveComparisonChannel,
24 resolveUpdateChannel,
25 type UpdateChannel,
26} from "@executor-js/api";
21 27
22import { Button } from "./button";28import { Button } from "./button";
23import { toast } from "./sonner";29import { toast } from "./sonner";
@@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = { · 23 unmodified lines
47 53
48// ── useLatestVersion ────────────────────────────────────────────────────54// ── useLatestVersion ────────────────────────────────────────────────────
49 55
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 */
64export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null {
65 if (currentVersion === undefined) return null;
66 return resolveComparisonChannel(currentVersion);
67}
68
50function useLatestVersion(currentVersion: string | undefined) {69function 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);
52 const [latestVersion, setLatestVersion] = useState<string | null>(null);77 const [latestVersion, setLatestVersion] = useState<string | null>(null);
53 78
54 useEffect(() => {79 useEffect(() => {
55 if (!currentVersion) return;80 if (fetchChannel === null) return;
56 let cancelled = false;81 let cancelled = false;
57 void Effect.runPromiseExit(82 void Effect.runPromiseExit(
58 Effect.tryPromise({83 Effect.tryPromise({
@@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { · 6 unmodified lines
65 }),90 }),
66 ).then((exit) => {91 ).then((exit) => {
67 if (!cancelled && Exit.isSuccess(exit)) {92 if (!cancelled && Exit.isSuccess(exit)) {
68 setLatestVersion(exit.value?.[channel] ?? null);93 setLatestVersion(exit.value?.[fetchChannel] ?? null);
69 }94 }
70 });95 });
71 return () => {96 return () => {
72 cancelled = true;97 cancelled = true;
73 };98 };
74 }, [channel, currentVersion]);99 }, [fetchChannel]);
75 100
76 const updateAvailable =101 const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
77 currentVersion !== undefined &&
78 latestVersion !== null &&
79 compareVersions(currentVersion, latestVersion) === -1;
80 102
81 return { latestVersion, updateAvailable, channel };103 return { latestVersion, updateAvailable, channel: displayChannel };
82}104}
83 105
84// ── Card chrome ──────────────────────────────────────────────────────────106// ── Card chrome ──────────────────────────────────────────────────────────