-
Notifications
You must be signed in to change notification settings - Fork 60
fix(cli): compare versions by semver in the update check #2394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,10 @@ import { getPackageJSON } from '@percy/cli-command/utils'; | |||||||
| const CACHE_FILE = path.resolve(url.fileURLToPath(import.meta.url), '../../.releases'); | ||||||||
| // max age the cache should be used for (3 days) | ||||||||
| const CACHE_MAX_AGE = 3 * 24 * 60 * 60 * 1000; | ||||||||
| // how many stable releases behind before the warning escalates | ||||||||
| const MANY_RELEASES_BEHIND = 10; | ||||||||
| // where users are pointed to see what changed | ||||||||
| const RELEASES_URL = 'https://github.com/percy/cli/releases'; | ||||||||
|
|
||||||||
| // Safely read from CACHE_FILE and return an object containing `data` mirroring what was previously | ||||||||
| // written using `writeToCache(data)`. An empty object is returned when older than CACHE_MAX_AGE, | ||||||||
|
|
@@ -44,12 +48,44 @@ function writeToCache(data) { | |||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| // Parse a version string into its comparable parts. Release tags are inconsistently prefixed with | ||||||||
| // 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()); | ||||||||
| if (!match) return null; | ||||||||
|
|
||||||||
| return { | ||||||||
| major: parseInt(match[1], 10), | ||||||||
| minor: parseInt(match[2], 10), | ||||||||
| patch: parseInt(match[3], 10), | ||||||||
| prerelease: match[4] || null, | ||||||||
| // a prerelease precedes the stable release of the same version, so it sorts lower | ||||||||
| stable: match[4] ? 0 : 1, | ||||||||
| // the version without any `v` prefix, for display | ||||||||
| version: match[0].replace(/^v/, '') | ||||||||
| }; | ||||||||
| } | ||||||||
|
|
||||||||
| // Compare two parsed versions, returning a negative number when `a` precedes `b`, a positive | ||||||||
| // number when it follows, and zero when they are equal. Prerelease identifiers are not compared | ||||||||
| // against each other, since only stable releases are ever compared - the sole prerelease involved | ||||||||
| // is the version currently installed, and a prerelease always precedes its own stable release. | ||||||||
| function compareVersions(a, b) { | ||||||||
| for (let part of ['major', 'minor', 'patch', 'stable']) { | ||||||||
| if (a[part] !== b[part]) return a[part] - b[part]; | ||||||||
| } | ||||||||
|
|
||||||||
| return 0; | ||||||||
| } | ||||||||
|
|
||||||||
| // Fetch and return release information for @percy/cli. | ||||||||
| async function fetchReleases(pkg) { | ||||||||
| let { request } = await import('@percy/client/utils'); | ||||||||
|
|
||||||||
| // fetch releases from the github api without retries | ||||||||
| let api = 'https://api.github.com/repos/percy/cli/releases'; | ||||||||
| // fetch releases from the github api without retries. a full page is requested since the majority | ||||||||
| // of releases are prereleases, and those are filtered out before comparing versions | ||||||||
| let api = 'https://api.github.com/repos/percy/cli/releases?per_page=100'; | ||||||||
| let data = await request(api, { | ||||||||
| headers: { 'User-Agent': pkg.name }, | ||||||||
| retries: 0 | ||||||||
|
|
@@ -78,19 +114,63 @@ export async function checkForUpdate() { | |||||||
| // request new release information if needed | ||||||||
| if (!releases) { | ||||||||
| releases = await fetchReleases(pkg); | ||||||||
| if (!cacheError) writeToCache(releases, log); | ||||||||
| if (!cacheError) writeToCache(releases); | ||||||||
| } | ||||||||
|
|
||||||||
| let current = parseVersion(pkg.version); | ||||||||
|
|
||||||||
| if (!current) { | ||||||||
| log.debug(`Unable to parse the current version: ${pkg.version}`); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| // 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); | ||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Mixed boolean/null short-circuit reads awkwardly
Suggested change
Reviewer: stack-code-reviewer |
||||||||
| if (parsed && !parsed.prerelease) acc.push(parsed); | ||||||||
| return acc; | ||||||||
| }, []); | ||||||||
|
|
||||||||
| if (!versions.length) { | ||||||||
| log.debug('No stable releases found to compare against'); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| // sort newest first rather than trusting the order releases were published in | ||||||||
| versions.sort((a, b) => compareVersions(b, a)); | ||||||||
| let [latest] = versions; | ||||||||
|
|
||||||||
| // already on the latest stable release, or ahead of it - nothing to warn about | ||||||||
| if (compareVersions(current, latest) >= 0) { | ||||||||
| log.debug(`Current version ${current.version} is up to date (latest is ${latest.version})`); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| // a prerelease is intentionally not the latest stable release, so counting releases behind is | ||||||||
| // meaningless. say what it actually is and what the latest stable release is instead | ||||||||
| if (current.prerelease) { | ||||||||
| log.warn('\nYou are using a pre-release build of @percy/cli. ' + | ||||||||
| `${colors.red(current.version)} -> ${colors.green(latest.version)} (latest stable)\n`); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| // check the current package version against released versions | ||||||||
| // don't include prerelease - alpha/beta versions | ||||||||
| let versions = releases.filter(r => !r.prerelease).map(r => r.tag.substr(1)); | ||||||||
| let age = versions.indexOf(pkg.version); | ||||||||
| // the number of stable releases newer than the current one. this is only a real count when the | ||||||||
| // current version falls within the window of releases fetched - otherwise it is a lower bound, | ||||||||
| // and reporting a lower bound as though it were exact is what made this warning misleading | ||||||||
| let behind = versions.filter(v => compareVersions(v, current) > 0).length; | ||||||||
| let known = compareVersions(current, versions[versions.length - 1]) >= 0; | ||||||||
| let versionChange = `${colors.red(current.version)} -> ${colors.green(latest.version)}`; | ||||||||
|
|
||||||||
| // a new version is available | ||||||||
| if (age !== 0) { | ||||||||
| log.warn(`\n${age > 0 && age < 10 ? 'A new version of @percy/cli is available!' : ( | ||||||||
| 'Heads up! The current version of @percy/cli is more than 10 releases behind!' | ||||||||
| )} ${colors.red(pkg.version)} -> ${colors.green(versions[0])}\n`); | ||||||||
| if (!known) { | ||||||||
| log.warn('\nHeads up! Your @percy/cli is significantly out of date. ' + | ||||||||
| `${versionChange}\nSee ${RELEASES_URL} for what changed.\n`); | ||||||||
| } else if (behind >= MANY_RELEASES_BEHIND || current.major < latest.major) { | ||||||||
| log.warn(`\nHeads up! Your @percy/cli is ${behind} ${behind === 1 ? 'release' : 'releases'} ` + | ||||||||
| `behind the latest release. ${versionChange}\n` + | ||||||||
| `See ${RELEASES_URL} for what changed.\n`); | ||||||||
| } else { | ||||||||
| log.warn(`\nA new version of @percy/cli is available! ${versionChange}\n`); | ||||||||
| } | ||||||||
| } catch (err) { | ||||||||
| log.debug('Unable to check for updates'); | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low]
parseVersiondrops tags carrying semver build metadataThe pattern has no branch for a
+buildsuffix, 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 atUnable to parse the current versionand 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.
Reviewer: stack-code-reviewer