Skip to content

Commit 1cd81d6

Browse files
authored
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
1 parent 6305b6d commit 1cd81d6

6 files changed

Lines changed: 269 additions & 16 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ export {
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

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
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", () => {
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", () => {
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", () => {
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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => {
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 (
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
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@
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>> = {
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) {
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 ──────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)