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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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:

letversions=releases.filter(r=>!r.prerelease).map(r=>r.tag.substr(1));letage=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 versionWarning shownReality
1.32.6-beta.3more than 10 releases behindbetas are filtered out of the list, so they are never found
1.32.8 (ahead of latest)more than 10 releases behindtells you to downgrade: 1.32.8 -> 1.32.7
1.32.0 (8 releases back)more than 10 releases behindthe 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 versionBeforeAfter
1.32.6-beta.3more 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.6A 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 windowmore 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-devforce-pushed the fix/cli-update-check-version-warning branch from 630d3e7 to a8d13c5CompareAugust 22, 2026 16:28

@aryanku-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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
ContributorAuthor

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
letmatch=/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?$/.exec(String(version).trim());
letmatch=/^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
ContributorAuthor

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
letparsed=!r.prerelease&&parseVersion(r.tag);
if(r.prerelease)returnacc;
letparsed=parseVersion(r.tag);

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassOnly a public GitHub releases URL and a docs link added.
HighSecurityAuthentication/authorization checks presentN/AUnauthenticated public API read; no auth surface.
HighSecurityInput validation and sanitizationPassRelease 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).
HighSecurityNo IDOR — resource ownership validatedN/ANo resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo database access.
HighCorrectnessLogic is correct, handles edge casesPassReviewer hand-traced every branch: the current >= latestprerelease!knownbehind >= 10 || major bump → default ordering has no gaps, no fallthrough, no double-warn.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassExisting try/catch retained; new bail-outs (unparseable version, no stable releases) log at debug and return rather than warning on an untrustworthy comparison.
HighCorrectnessNo race conditions or concurrency issuesN/ASingle sequential path; no shared mutable state.
MediumTestingNew code has corresponding testsPass11 new specs; 21 specs in the suite; 100% statement/branch/function/line coverage (package gate requires 100%).
MediumTestingError paths and edge cases testedPassUnparseable current version, unparseable release tags, no stable releases, prerelease-flag/tag mismatch, cache read/write failures, request failure.
MediumTestingExisting tests still pass (no regressions)Pass46/47 checks green on a8d13c5; one Test @percy/core job still running at time of writing. Percy visual: "no visual changes found".
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStill exactly one bounded request (per_page=100, hard-capped by the API), cached 3 days.
MediumPerformanceLong-running tasks use background jobsN/ASingle non-retried request on startup, unchanged in shape.
MediumQualityFollows existing codebase patternsPassSame let-style, comment voice, logger namespaces and cache contract as the surrounding module.
MediumQualityChanges are focused (single concern)PassTwo files, one concern; no drive-by edits.
LowQualityMeaningful names, no dead codePassAn unreachable prerelease-identifier comparator was removed during development after the coverage gate exposed it as dead.
LowQualityComments explain why, not whatPassComments record the reasoning (why a lower bound must not be printed as an exact count; why the prerelease-identifier comparison is intentionally absent).
LowQualityNo unnecessary dependencies addedPassNo 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 ownerAugust 22, 2026 16:58
Sign up for freeto 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

@aryanku-dev