* 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
Unified · Split
| @@ -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. | ||
| @@ -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, |
| @@ -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 | ||
| 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 | |||
| 56 | describe("resolveDistTags", () => { | 126 | describe("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 | }); |
| @@ -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 | */ | ||
| 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 | |||
| 105 | // ── dist-tags resolution ────────────────────────────────────────────────── | 152 | // ── dist-tags resolution ────────────────────────────────────────────────── |
| 106 | 153 | ||
| 107 | export type DistTags = Partial<Record<UpdateChannel, string>>; | 154 | export 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 | }; |
| @@ -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 | }); | ||
| @@ -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 as | 15 | // 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. | ||
| 17 | import { useCallback, useEffect, useState } from "react"; | 18 | import { useCallback, useEffect, useState } from "react"; |
| 18 | 19 | ||
| 19 | import { Effect, Exit } from "effect"; | 20 | 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"; | ||
| 21 | 27 | ||
| 22 | import { Button } from "./button"; | 28 | import { Button } from "./button"; |
| 23 | import { toast } from "./sonner"; | 29 | import { 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 | */ | ||
| 64 | export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null { | ||
| 65 | if (currentVersion === undefined) return null; | ||
| 66 | return resolveComparisonChannel(currentVersion); | ||
| 67 | } | ||
| 68 | |||
| 50 | function useLatestVersion(currentVersion: string | undefined) { | 69 | 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); | ||
| 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 ────────────────────────────────────────────────────────── |