Skip to content

Commit

Permalink
feat: notify of new major RN version at most once a week (react-nativ…
Browse files Browse the repository at this point in the history
…e-community#268)

Summary:
---------

This PR addresses the first part of react-native-community#189: notifying the user when a newer version of React Native is available to upgrade to when using the CLI.

**Behaviour**
* The remote release check mechanism uses the [GitHub API](https://developer.github.com/v3/repos/releases/#get-the-latest-release).
* The mechanism is triggered for any CLI command.
* Nothing will be displayed if the project is already up-to-date.
* The release data returned from the API call is cached per-project. Caching is necessary to not hit the GitHub API rate limit.
* For the time being, there is no way to "mute" this behaviour if a project is intentionally using an older RN version because we have not discussed yet how the UX for that will be.

**Caveats**
The GitHub API we are using is specific to [Releases](https://help.github.com/en/articles/creating-releases), so we need to make sure that each new version of React Native has one. For example, 0.59.1 has been cut and published on NPM on March 14th but the release has been made only on the 25th. During that interval, the CLI would have not notified users of the newer version.

Test Plan:
----------

* Clone this branch
* Install dependencies with `yarn install`
* Test the behaviour in newer and older projects by calling `cli.js` directly from the cloned repository as described in `CONTRIBUTING.md`.

Screenshots:
---------
Linking a module in an older project:
![Screenshot from 2019-03-25 19-22-14](https://user-images.githubusercontent.com/1713151/54944577-1a523680-4f34-11e9-8d28-5c4b3e00b8f9.png)

Output when a project is up-to-date (showing debug messages as normally there would be no output):
![Screenshot from 2019-03-25 19-35-42](https://user-images.githubusercontent.com/1713151/54945093-55a13500-4f35-11e9-879e-69780ae7a59c.png)
  • Loading branch information
matei-radu authored and thymikee committed Jun 6, 2019
1 parent 7dec654 commit 77944a8
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 9 deletions.
42 changes: 35 additions & 7 deletions packages/cli/src/cliEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import init from './commands/init/initCompat';
import assertRequiredOptions from './tools/assertRequiredOptions';
import {logger} from '@react-native-community/cli-tools';
import {setProjectDir} from './tools/packageManager';
import resolveNodeModuleDir from './tools/config/resolveNodeModuleDir';
import getLatestRelease from './tools/releaseChecker/getLatestRelease';
import printNewRelease from './tools/releaseChecker/printNewRelease';
import pkgJson from '../package.json';
import loadConfig from './tools/config';

Expand Down Expand Up @@ -107,16 +110,18 @@ const addCommand = (command: CommandT, ctx: ConfigT) => {
const cmd = commander
.command(command.name)
.description(command.description)
.action(function handleAction(...args) {
.action(async function handleAction(...args) {
const passedOptions = this.opts();
const argv: Array<string> = Array.from(args).slice(0, -1);

Promise.resolve()
.then(() => {
assertRequiredOptions(options, passedOptions);
return command.func(argv, ctx, passedOptions);
})
.catch(handleError);
try {
assertRequiredOptions(options, passedOptions);
await command.func(argv, ctx, passedOptions);
} catch (error) {
handleError(error);
} finally {
checkForNewRelease(ctx.root);
}
});

cmd.helpInformation = printHelpInformation.bind(
Expand Down Expand Up @@ -186,6 +191,29 @@ async function setupAndRun() {
logger.setVerbose(commander.verbose);
}

async function checkForNewRelease(root: string) {
try {
const {version: currentVersion} = require(path.join(
resolveNodeModuleDir(root, 'react-native'),
'package.json',
));
const {name} = require(path.join(root, 'package.json'));
const latestRelease = await getLatestRelease(name, currentVersion);

if (latestRelease) {
printNewRelease(name, latestRelease, currentVersion);
}
} catch (e) {
// We let the flow continue as this component is not vital for the rest of
// the CLI.
logger.debug(
'Cannot detect current version of React Native, ' +
'skipping check for a newer release',
);
logger.debug(e);
}
}

export default {
run,
init,
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/src/tools/releaseChecker/getLatestRelease.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* @flow
*/
import https from 'https';
import semver from 'semver';
import logger from '../logger';
import cacheManager from './releaseCacheManager';

export type Release = {
version: string,
changelogUrl: string,
};

/**
* Checks via GitHub API if there is a newer stable React Native release and,
* if it exists, returns the release data.
*
* If the latest release is not newer or if it's a prerelease, the function
* will return undefined.
*/
export default async function getLatestRelease(
name: string,
currentVersion: string,
) {
logger.debug('Checking for a newer version of React Native');
try {
logger.debug(`Current version: ${currentVersion}`);

const cachedLatest = cacheManager.get(name, 'latestVersion');

if (cachedLatest) {
logger.debug(`Cached release version: ${cachedLatest}`);
}

const aWeek = 7 * 24 * 60 * 60 * 1000;
const lastChecked = cacheManager.get(name, 'lastChecked');
const now = new Date();
if (lastChecked && now - new Date(lastChecked) < aWeek) {
logger.debug('Cached release is still recent, skipping remote check');
return;
}

logger.debug('Checking for newer releases on GitHub');
const eTag = cacheManager.get(name, 'eTag');
const latestVersion = await getLatestRnDiffPurgeVersion(name, eTag);
logger.debug(`Latest release: ${latestVersion}`);

if (
semver.compare(latestVersion, currentVersion) === 1 &&
!semver.prerelease(latestVersion)
) {
return {
version: latestVersion,
changelogUrl: buildChangelogUrl(latestVersion),
};
}
} catch (e) {
logger.debug(
'Something went wrong with remote version checking, moving on',
);
logger.debug(e);
}
}

function buildChangelogUrl(version: string) {
return `https://github.com/facebook/react-native/releases/tag/v${version}`;
}

/**
* Returns the most recent React Native version available to upgrade to.
*/
async function getLatestRnDiffPurgeVersion(name: string, eTag: ?string) {
const options = {
hostname: 'api.github.com',
path: '/repos/react-native-community/rn-diff-purge/tags',
// https://developer.github.com/v3/#user-agent-required
headers: ({'User-Agent': 'React-Native-CLI'}: Headers),
};

if (eTag) {
options.headers['If-None-Match'] = eTag;
}

const response = await httpsGet(options);

// Remote is newer.
if (response.statusCode === 200) {
const latestVersion = JSON.parse(response.body)[0].name.substring(8);

// Update cache only if newer release is stable.
if (!semver.prerelease(latestVersion)) {
logger.debug(`Saving ${response.eTag} to cache`);
cacheManager.set(name, 'eTag', response.eTag);
cacheManager.set(name, 'latestVersion', latestVersion);
}

return latestVersion;
}

// Cache is still valid.
if (response.statusCode === 304) {
const latestVersion = cacheManager.get(name, 'latestVersion');
if (latestVersion) {
return latestVersion;
}
}

// Should be returned only if something went wrong.
return '0.0.0';
}

type Headers = {
'User-Agent': string,
[header: string]: string,
};

function httpsGet(options: any) {
return new Promise((resolve, reject) => {
https
.get(options, result => {
let body = '';

result.setEncoding('utf8');
result.on('data', data => {
body += data;
});

result.on('end', () => {
resolve({
body,
eTag: result.headers.etag,
statusCode: result.statusCode,
});
});

result.on('error', error => reject(error));
})
.on('error', error => reject(error));
});
}
26 changes: 26 additions & 0 deletions packages/cli/src/tools/releaseChecker/printNewRelease.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @flow
*/
import chalk from 'chalk';
import logger from '../logger';
import type {Release} from './getLatestRelease';
import cacheManager from './releaseCacheManager';

/**
* Notifies the user that a newer version of React Native is available.
*/
export default function printNewRelease(
name: string,
latestRelease: Release,
currentVersion: string,
) {
logger.info(
`React Native v${
latestRelease.version
} is now available (your project is running on v${currentVersion}).`,
);
logger.info(`Changelog: ${chalk.dim.underline(latestRelease.changelogUrl)}.`);
logger.info(`To upgrade, run "${chalk.bold('react-native upgrade')}".`);

cacheManager.set(name, 'lastChecked', new Date().toISOString());
}
69 changes: 69 additions & 0 deletions packages/cli/src/tools/releaseChecker/releaseCacheManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @flow
*/
import path from 'path';
import fs from 'fs';
import os from 'os';
import mkdirp from 'mkdirp';
import logger from '../logger';

type ReleaseCacheKey = 'eTag' | 'lastChecked' | 'latestVersion';
type Cache = {[key: ReleaseCacheKey]: string};

function loadCache(name: string): ?Cache {
try {
const cacheRaw = fs.readFileSync(
path.resolve(getCacheRootPath(), name),
'utf8',
);
const cache = JSON.parse(cacheRaw);
return cache;
} catch (e) {
if (e.code === 'ENOENT') {
// Create cache file since it doesn't exist.
saveCache(name, {});
}
logger.debug('No release cache found');
}
}

function saveCache(name: string, cache: Cache) {
fs.writeFileSync(
path.resolve(getCacheRootPath(), name),
JSON.stringify(cache, null, 2),
);
}

/**
* Returns the path string of `$HOME/.react-native-cli`.
*
* In case it doesn't exist, it will be created.
*/
function getCacheRootPath() {
const cachePath = path.resolve(os.homedir(), '.react-native-cli', 'cache');
if (!fs.existsSync(cachePath)) {
mkdirp(cachePath);
}

return cachePath;
}

function get(name: string, key: ReleaseCacheKey): ?string {
const cache = loadCache(name);
if (cache) {
return cache[key];
}
}

function set(name: string, key: ReleaseCacheKey, value: string) {
const cache = loadCache(name);
if (cache) {
cache[key] = value;
saveCache(name, cache);
}
}

export default {
get,
set,
};
4 changes: 2 additions & 2 deletions packages/cli/src/tools/walk.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/

import fs from 'fs';
import path from 'path';

function walk(current) {
function walk(current: string) {
if (!fs.lstatSync(current).isDirectory()) {
return [current];
}
Expand Down

0 comments on commit 77944a8

Please sign in to comment.