Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/update-check-suppress-false-nags.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/core/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ export {
checkForUpdate,
resolveDistTags,
resolveUpdateChannel,
resolveComparisonChannel,
compareVersions,
isUnstampedVersion,
isUpdateAvailable,
EXECUTOR_PACKAGE_NAME,
type UpdateStatus,
type UpdateChannel,
Expand Down
105 changes: 103 additions & 2 deletions packages/core/api/src/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
__resetDistTagsCache,
checkForUpdate,
compareVersions,
isUnstampedVersion,
isUpdateAvailable,
resolveComparisonChannel,
resolveDistTags,
resolveUpdateChannel,
} from "./update-check";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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" }) },
Expand All @@ -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();
});
});
61 changes: 58 additions & 3 deletions packages/core/api/src/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<UpdateChannel, string>>;
Expand Down Expand Up @@ -223,9 +270,17 @@ export const checkForUpdate = async (
): Promise<UpdateStatus> => {
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 };
};
58 changes: 58 additions & 0 deletions packages/react/src/components/update-card.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 33 additions & 11 deletions packages/react/src/components/update-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = {

// ── 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<string | null>(null);

useEffect(() => {
if (!currentVersion) return;
if (fetchChannel === null) return;
let cancelled = false;
void Effect.runPromiseExit(
Effect.tryPromise({
Expand All @@ -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 ──────────────────────────────────────────────────────────
Expand Down
Loading