Skip to content

fix(cli): compare versions by semver in the update check - #2394

Open
aryanku-dev wants to merge 1 commit into
masterfrom
fix/cli-update-check-version-warning
Open

fix(cli): compare versions by semver in the update check#2394
aryanku-dev wants to merge 1 commit into
masterfrom
fix/cli-update-check-version-warning

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

Problem

The update check tells you the wrong thing in several common situations. Most visibly, any beta build reports being more than ten releases behind:

[percy] Heads up! The current version of @percy/cli is more than 10 releases behind! 1.32.6-beta.3 -> 1.32.7

All of it traces back to one line, which uses a list index as a distance metric:

let versions = releases.filter(r => !r.prerelease).map(r => r.tag.substr(1));
let age = versions.indexOf(pkg.version);

indexOf returns -1 for anything not in the stable list, and -1 fails the age > 0 && age < 10 guard, so it falls through to the "more than 10 releases behind" branch. Three unrelated situations land there:

Installed version Warning shown Reality
1.32.6-beta.3 more than 10 releases behind betas are filtered out of the list, so they are never found
1.32.8 (ahead of latest) more than 10 releases behind tells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back) more than 10 releases behind the API page holds 30 releases of which only 8 are stable, so the "10" is unrelated to the real distance

Two latent bugs came along with it:

  • tag.substr(1) strips the first character unconditionally. Percy publishes tags both with and without a v prefix, so 1.32.5-beta.1 becomes .32.5-beta.1.
  • "Latest" was versions[0] — whichever release GitHub listed first by publish date, not the highest version. A backported patch published after a newer release would be reported as latest.

Fix

  • Parse versions with semver semantics (parseVersion / compareVersions) instead of doing index arithmetic. The v prefix is optional, unparseable input returns null and skips the check rather than warning on a comparison that cannot be trusted.
  • Take latest as the semver maximum rather than trusting publish order.
  • Request per_page=100, which widens the window of stable releases from 8 to 22 so a real count is available for most versions in the wild.
  • Report what actually applies to the installed version.
Installed version Before After
1.32.6-beta.3 more than 10 releases behind! You are using a pre-release build of @percy/cli. 1.32.6-beta.3 -> 1.32.7 (latest stable)
1.32.6 A new version is available! unchanged
1.32.0 (8 back) more than 10 releases behind! A new version of @percy/cli is available! 1.32.0 -> 1.32.7
12 back, within window more than 10 releases behind! Heads up! Your @percy/cli is 12 releases behind the latest release. + releases link
1.30.2 (outside window) more than 10 releases behind! Heads up! Your @percy/cli is significantly out of date. 1.30.2 -> 1.32.7 + releases link
1.32.8 (ahead) more than 10 releases behind! silent (debug log only)

One deliberate call worth a reviewer's attention: when the installed version predates every release fetched, the message says "significantly out of date" with no number, because any count there is only a lower bound — stating a floor as though it were exact is what made the original warning misleading. The two version numbers carry the real information. Counts are printed only when exact.

The cache format is unchanged, so existing .releases files keep working.

Testing

packages/cli — 38 specs pass at 100% statement/branch/function/line coverage (the package gate requires 100%).

New specs cover: prerelease in use, prerelease ahead of latest stable, version ahead of latest, exact count when far behind, escalation one major behind, no count when outside the fetched window, semver ordering vs publish ordering, tags without a v prefix, tags whose prerelease flag disagrees with the tag, unparseable current version, and unparseable release tags.

The logic was also replayed verbatim against the live percy/cli releases API across 12 version scenarios.

The update check derived "how far behind" from `versions.indexOf(pkg.version)`
against a list with prereleases filtered out. Anything absent from that list
returned -1, which failed the `age > 0 && age < 10` guard and fell through to
"more than 10 releases behind" — so three unrelated situations all produced the
same misleading warning:

- any prerelease build, since betas are filtered out of the list and are
  therefore never found (`1.32.6-beta.3` -> "more than 10 releases behind")
- any version newer than the latest release, which told users to downgrade
- versions only a few releases old, because the API page holds 30 releases of
  which just 8 are stable, so the "10" was unrelated to the real distance

Two latent bugs went with it: `tag.substr(1)` blindly stripped the first
character, mangling the release tags that are published without a `v` prefix,
and "latest" was whichever release GitHub listed first rather than the highest
version.

Replace the index arithmetic with semver parsing and comparison, take the
latest release as the semver maximum, and request a full page of releases so
the window of stable releases (8 -> 22) is wide enough for the count to be
real. Messages now describe the actual situation: prereleases are told the
latest stable version, versions ahead of the latest warn nothing, and a count
of releases behind is only printed when it is exact rather than a lower bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aryanku-dev
aryanku-dev force-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5 Compare August 22, 2026 16:28

@aryanku-dev aryanku-dev left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.

// a `v`, so the prefix is optional. Returns null for anything unparseable so callers can bail out
// rather than warn about a comparison that cannot be trusted.
function parseVersion(version) {
let match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] parseVersion drops tags carrying semver build metadata

The pattern has no branch for a +build suffix, so such a tag is silently excluded from the comparison set. More importantly, if the installed version ever carried build metadata, the check bails out at Unable to parse the current version and the user is never told an update exists. No current percy/cli tag uses +, so this is latent rather than active.

Suggestion: tolerate and discard it, or note the limitation in a comment.

Suggested change
let match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
let match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+[\w.-]+)?$/.exec(String(version).trim());

Reviewer: stack-code-reviewer

// only compare against stable releases - alpha/beta versions are excluded both by the release
// flag and by their own version, since the flag is set by hand and is sometimes wrong
let versions = releases.reduce((acc, r) => {
let parsed = !r.prerelease && parseVersion(r.tag);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Mixed boolean/null short-circuit reads awkwardly

parsed ends up as false, null, or an object, conflating a short-circuited boolean with a parse result. Functionally correct — the prerelease-flag/tag mismatch spec covers it — but an early return separates the two concerns:

Suggested change
let parsed = !r.prerelease && parseVersion(r.tag);
if (r.prerelease) return acc;
let parsed = parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2394Head: a8d13c5Reviewers: stack-code-reviewer

Summary

Replaces the CLI update check's list-index distance calculation (versions.indexOf(pkg.version)) with semver parsing and comparison, so that pre-release builds, versions ahead of the latest release, and versions outside the fetched release page no longer all collapse into a single misleading "more than 10 releases behind" warning. Also takes latest as the semver maximum rather than GitHub's publish order, widens the release fetch to per_page=100 (8 → 22 stable releases visible) so a release-behind count is usually exact, and prints a count only when it is exact rather than a lower bound.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass Only a public GitHub releases URL and a docs link added.
High Security Authentication/authorization checks present N/A Unauthenticated public API read; no auth surface.
High Security Input validation and sanitization Pass Release tags are untrusted external input; anchored regex validates them and parseVersion returns null for anything unparseable. Reviewer confirmed no ReDoS risk in [\w.-]+ (no nested/ambiguous quantifiers).
High Security No IDOR — resource ownership validated N/A No resource access.
High Security No SQL injection (parameterized queries) N/A No database access.
High Correctness Logic is correct, handles edge cases Pass Reviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
High Correctness Error handling is explicit, no swallowed exceptions Pass Existing try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
High Correctness No race conditions or concurrency issues N/A Single sequential path; no shared mutable state.
Medium Testing New code has corresponding tests Pass 11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
Medium Testing Error paths and edge cases tested Pass Unparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
Medium Testing Existing tests still pass (no regressions) Pass 46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
Medium Performance No N+1 queries or unbounded data fetching Pass Still exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
Medium Performance Long-running tasks use background jobs N/A Single non-retried request on startup, unchanged in shape.
Medium Quality Follows existing codebase patterns Pass Same let-style, comment voice, logger namespaces and cache contract as the surrounding module.
Medium Quality Changes are focused (single concern) Pass Two files, one concern; no drive-by edits.
Low Quality Meaningful names, no dead code Pass An unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
Low Quality Comments explain why, not what Pass Comments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
Low Quality No unnecessary dependencies added Pass No new dependency; ~12 lines of semver comparison hand-rolled instead of adding semver to a package that does not currently depend on it.

Findings

  • File: packages/cli/src/update.js:55

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: parseVersion's regex has no support for semver build metadata (+build). Such a tag is silently excluded from the comparison set; more importantly, if the installed version ever carried build metadata the whole check bails out at Unable to parse the current version and the user is never told an update exists. The reviewer checked git tag history and found no percy/cli tag using +, so this is latent rather than active.

  • Suggestion: Tolerate and discard the suffix — (?:\+[\w.-]+)?$ — or note the limitation in a comment.

  • File: packages/cli/src/update.js:130

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: let parsed = !r.prerelease && parseVersion(r.tag); makes parsed either false, null, or an object, conflating a short-circuited boolean with a parse result. Functionally correct (covered by the prerelease-flag/tag mismatch spec) but awkward to read.

  • Suggestion: Early-return the prerelease case instead: if (r.prerelease) return acc; then let parsed = parseVersion(r.tag);.

  • File: packages/cli/test/update.test.js

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: The suite covers behind = 1 and behind = 12 but not the MANY_RELEASES_BEHIND boundary itself — behind = 10 (escalates) and behind = 9 (does not). The bug being fixed lived at exactly this boundary in the old code (age > 0 && age < 10), so the boundary is the most regression-prone point in the change. Branch coverage is already 100%, so the gate would not catch a threshold change.

  • Suggestion: Add a spec asserting the message flips between 9 and 10 releases behind.

Notes (not defects)

  • per_page=100 is a single page, so installs older than the ~22-release stable window still get the lower-bound "significantly out of date" message rather than an exact count. The reviewer confirmed this is deliberate and documented in the code, and preferable to the old fixed "10+" bucket. Worth revisiting only if the stable release cadence changes.
  • The reviewer independently confirmed the two modified pre-existing specs were strengthened, not weakened: each gained a debug-log assertion for the new up-to-date path while keeping all prior assertions.
  • writeToCache(releases, log)writeToCache(releases) drops a stray second argument the function never accepted; no behavior change.

Verdict: PASS — no correctness defects found; three Low/nit polish items, none blocking.

@aryanku-dev
aryanku-dev marked this pull request as ready for review August 22, 2026 16:58
@aryanku-dev
aryanku-dev requested a review from a team as a code owner August 22, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant