Add more prepublish checks

This commit is contained in:
Fabio Berger
2018-06-18 16:55:59 +02:00
parent 19668b9b48
commit 8633fa7024
8 changed files with 332 additions and 39 deletions

View File

@@ -1,9 +1,103 @@
import * as fs from 'fs';
import * as _ from 'lodash';
import * as path from 'path';
import { exec as execAsync } from 'promisify-child-process';
import { constants } from './constants';
import { Changelog, PackageRegistryJson } from './types';
import { changelogUtils } from './utils/changelog_utils';
import { npmUtils } from './utils/npm_utils';
import { semverUtils } from './utils/semver_utils';
import { utils } from './utils/utils';
async function prepublishChecksAsync(): Promise<void> {
const shouldIncludePrivate = false;
const updatedPublicLernaPackages = await utils.getUpdatedLernaPackagesAsync(shouldIncludePrivate);
await checkCurrentVersionMatchesLatestPublishedNPMPackageAsync(updatedPublicLernaPackages);
await checkChangelogFormatAsync(updatedPublicLernaPackages);
await checkGitTagsForNextVersionAndDeleteIfExistAsync(updatedPublicLernaPackages);
await checkPublishRequiredSetupAsync();
}
async function checkGitTagsForNextVersionAndDeleteIfExistAsync(
updatedPublicLernaPackages: LernaPackage[],
): Promise<void> {
const packageNames = _.map(updatedPublicLernaPackages, lernaPackage => lernaPackage.package.name);
const localGitTags = await utils.getLocalGitTagsAsync();
const localTagVersionsByPackageName = await utils.getGitTagsByPackageNameAsync(packageNames, localGitTags);
const remoteGitTags = await utils.getRemoteGitTagsAsync();
const remoteTagVersionsByPackageName = await utils.getGitTagsByPackageNameAsync(packageNames, remoteGitTags);
for (const lernaPackage of updatedPublicLernaPackages) {
const currentVersion = lernaPackage.package.version;
const packageName = lernaPackage.package.name;
const packageLocation = lernaPackage.location;
const nextVersion = await utils.getNextPackageVersionAsync(currentVersion, packageName, packageLocation);
const localTagVersions = localTagVersionsByPackageName[packageName];
if (_.includes(localTagVersions, nextVersion)) {
const tagName = `${packageName}@${nextVersion}`;
await utils.removeLocalTagAsync(tagName);
}
const remoteTagVersions = remoteTagVersionsByPackageName[packageName];
if (_.includes(remoteTagVersions, nextVersion)) {
const tagName = `:refs/tags/${packageName}@${nextVersion}`;
await utils.removeRemoteTagAsync(tagName);
}
}
}
async function checkCurrentVersionMatchesLatestPublishedNPMPackageAsync(
updatedPublicLernaPackages: LernaPackage[],
): Promise<void> {
for (const lernaPackage of updatedPublicLernaPackages) {
const packageName = lernaPackage.package.name;
const packageVersion = lernaPackage.package.version;
const packageRegistryJsonIfExists = await npmUtils.getPackageRegistryJsonIfExistsAsync(packageName);
if (_.isUndefined(packageRegistryJsonIfExists)) {
continue; // noop for packages not yet published to NPM
}
const allVersionsIncludingUnpublished = npmUtils.getPreviouslyPublishedVersions(packageRegistryJsonIfExists);
const latestNPMVersion = semverUtils.getLatestVersion(allVersionsIncludingUnpublished);
if (packageVersion !== latestNPMVersion) {
throw new Error(
`Found verson ${packageVersion} in package.json but version ${latestNPMVersion}
on NPM (could be unpublished version) for ${packageName}. These versions must match. If you update
the package.json version, make sure to also update the internal dependency versions too.`,
);
}
}
}
async function checkChangelogFormatAsync(updatedPublicLernaPackages: LernaPackage[]): Promise<void> {
for (const lernaPackage of updatedPublicLernaPackages) {
const packageName = lernaPackage.package.name;
const changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, lernaPackage.location);
const currentVersion = lernaPackage.package.version;
if (!_.isEmpty(changelog)) {
const lastEntry = changelog[0];
const doesLastEntryHaveTimestamp = !_.isUndefined(lastEntry.timestamp);
if (semverUtils.lessThan(lastEntry.version, currentVersion)) {
throw new Error(
`CHANGELOG version cannot be below current package version.
Update ${packageName}'s CHANGELOG. It's current version is ${currentVersion}
but the latest CHANGELOG entry is: ${lastEntry.version}`,
);
} else if (semverUtils.greaterThan(lastEntry.version, currentVersion) && doesLastEntryHaveTimestamp) {
// Remove incorrectly added timestamp
delete changelog[0].timestamp;
// Save updated CHANGELOG.json
await changelogUtils.writeChangelogJsonFileAsync(lernaPackage.location, changelog);
utils.log(`${packageName}: Removed timestamp from latest CHANGELOG.json entry.`);
}
}
}
}
async function checkPublishRequiredSetupAsync(): Promise<void> {
// check to see if logged into npm before publishing
try {
@@ -65,7 +159,7 @@ async function checkPublishRequiredSetupAsync(): Promise<void> {
}
}
checkPublishRequiredSetupAsync().catch(err => {
prepublishChecksAsync().catch(err => {
utils.log(err.message);
process.exit(1);
});

View File

@@ -119,19 +119,14 @@ async function updateChangeLogsAsync(updatedPublicLernaPackages: LernaPackage[])
const packageToVersionChange: PackageToVersionChange = {};
for (const lernaPackage of updatedPublicLernaPackages) {
const packageName = lernaPackage.package.name;
const changelogJSONPath = path.join(lernaPackage.location, 'CHANGELOG.json');
const changelogJSON = utils.getChangelogJSONOrCreateIfMissing(changelogJSONPath);
let changelog: Changelog;
try {
changelog = JSON.parse(changelogJSON);
} catch (err) {
throw new Error(
`${lernaPackage.package.name}'s CHANGELOG.json contains invalid JSON. Please fix and try again.`,
);
}
let changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, lernaPackage.location);
const currentVersion = lernaPackage.package.version;
const shouldAddNewEntry = changelogUtils.shouldAddNewChangelogEntry(currentVersion, changelog);
const shouldAddNewEntry = changelogUtils.shouldAddNewChangelogEntry(
lernaPackage.package.name,
currentVersion,
changelog,
);
if (shouldAddNewEntry) {
// Create a new entry for a patch version with generic changelog entry.
const nextPatchVersion = utils.getNextPatchVersion(currentVersion);
@@ -160,14 +155,11 @@ async function updateChangeLogsAsync(updatedPublicLernaPackages: LernaPackage[])
}
// Save updated CHANGELOG.json
fs.writeFileSync(changelogJSONPath, JSON.stringify(changelog, null, '\t'));
await utils.prettifyAsync(changelogJSONPath, constants.monorepoRootPath);
await changelogUtils.writeChangelogJsonFileAsync(lernaPackage.location, changelog);
utils.log(`${packageName}: Updated CHANGELOG.json`);
// Generate updated CHANGELOG.md
const changelogMd = changelogUtils.generateChangelogMd(changelog);
const changelogMdPath = path.join(lernaPackage.location, 'CHANGELOG.md');
fs.writeFileSync(changelogMdPath, changelogMd);
await utils.prettifyAsync(changelogMdPath, constants.monorepoRootPath);
await changelogUtils.writeChangelogMdFileAsync(lernaPackage.location, changelog);
utils.log(`${packageName}: Updated CHANGELOG.md`);
}

View File

@@ -27,3 +27,16 @@ export enum SemVerIndex {
export interface PackageToVersionChange {
[name: string]: string;
}
export interface PackageRegistryJson {
versions: {
[version: string]: any;
};
time: {
[version: string]: string;
};
}
export interface GitTagsByPackageName {
[packageName: string]: string[];
}

View File

@@ -1,8 +1,15 @@
import * as fs from 'fs';
import * as _ from 'lodash';
import * as moment from 'moment';
import * as path from 'path';
import { exec as execAsync } from 'promisify-child-process';
import semverSort = require('semver-sort');
import { constants } from '../constants';
import { Change, Changelog, VersionChangelog } from '../types';
import { semverUtils } from './semver_utils';
const CHANGELOG_MD_HEADER = `
<!--
This file is auto-generated using the monorepo-scripts package. Don't edit directly.
@@ -44,12 +51,58 @@ export const changelogUtils = {
return changelogMd;
},
shouldAddNewChangelogEntry(currentVersion: string, changelog: Changelog): boolean {
shouldAddNewChangelogEntry(packageName: string, currentVersion: string, changelog: Changelog): boolean {
if (_.isEmpty(changelog)) {
return true;
}
const lastEntry = changelog[0];
if (semverUtils.lessThan(lastEntry.version, currentVersion)) {
throw new Error(
`Found CHANGELOG version lower then current package version. ${packageName} current: ${currentVersion}, Changelog: ${
lastEntry.version
}`,
);
}
const isLastEntryCurrentVersion = lastEntry.version === currentVersion;
return isLastEntryCurrentVersion;
},
getChangelogJSONIfExists(changelogPath: string): string | undefined {
try {
const changelogJSON = fs.readFileSync(changelogPath, 'utf-8');
return changelogJSON;
} catch (err) {
return undefined;
}
},
getChangelogOrCreateIfMissing(packageName: string, packageLocation: string): Changelog {
const changelogJSONPath = path.join(packageLocation, 'CHANGELOG.json');
let changelogJsonIfExists = this.getChangelogJSONIfExists(changelogJSONPath);
if (_.isUndefined(changelogJsonIfExists)) {
// If none exists, create new, empty one.
changelogJsonIfExists = '[]';
fs.writeFileSync(changelogJSONPath, changelogJsonIfExists);
}
let changelog: Changelog;
try {
changelog = JSON.parse(changelogJsonIfExists);
} catch (err) {
throw new Error(`${packageName}'s CHANGELOG.json contains invalid JSON. Please fix and try again.`);
}
return changelog;
},
async writeChangelogJsonFileAsync(packageLocation: string, changelog: Changelog): Promise<void> {
const changelogJSONPath = path.join(packageLocation, 'CHANGELOG.json');
fs.writeFileSync(changelogJSONPath, JSON.stringify(changelog, null, '\t'));
await this.prettifyAsync(changelogJSONPath, constants.monorepoRootPath);
},
async writeChangelogMdFileAsync(packageLocation: string, changelog: Changelog): Promise<void> {
const changelogMarkdownPath = path.join(packageLocation, 'CHANGELOG.md');
fs.writeFileSync(changelogMarkdownPath, JSON.stringify(changelog, null, '\t'));
await this.prettifyAsync(changelogMarkdownPath, constants.monorepoRootPath);
},
async prettifyAsync(filePath: string, cwd: string): Promise<void> {
await execAsync(`prettier --write ${filePath} --config .prettierrc`, {
cwd,
});
},
};

View File

@@ -0,0 +1,28 @@
import 'isomorphic-fetch';
import * as _ from 'lodash';
import { PackageRegistryJson } from '../types';
const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
const SUCCESS_STATUS = 200;
const NOT_FOUND_STATUS = 404;
export const npmUtils = {
async getPackageRegistryJsonIfExistsAsync(packageName: string): Promise<PackageRegistryJson | undefined> {
const url = `${NPM_REGISTRY_BASE_URL}/${packageName}`;
const response = await fetch(url);
if (response.status === NOT_FOUND_STATUS) {
return undefined;
} else if (response.status !== SUCCESS_STATUS) {
throw new Error(`Request to ${url} failed. Check your internet connection and that npmjs.org is up.`);
}
const packageRegistryJson = await response.json();
return packageRegistryJson;
},
getPreviouslyPublishedVersions(packageRegistryJson: PackageRegistryJson): string[] {
const timeWithOnlyVersions = _.omit(packageRegistryJson.time, ['modified', 'created']);
const versions = _.keys(timeWithOnlyVersions);
return versions;
},
};

View File

@@ -0,0 +1,56 @@
import * as _ from 'lodash';
import semverSort = require('semver-sort');
// Regex that matches semantic versions only including digits and dots.
const SEM_VER_REGEX = /^(\d+\.){1}(\d+\.){1}(\d+){1}$/gm;
export const semverUtils = {
/**
* Checks whether version a is lessThan version b. Supplied versions must be
* Semantic Versions containing only numbers and dots (e.g 1.4.0).
* @param a version of interest
* @param b version to compare a against
* @return Whether version a is lessThan version b
*/
lessThan(a: string, b: string): boolean {
this.assertValidSemVer('a', a);
this.assertValidSemVer('b', b);
if (a === b) {
return false;
}
const sortedVersions = semverSort.desc([a, b]);
const isALessThanB = sortedVersions[0] === b;
return isALessThanB;
},
/**
* Checks whether version a is greaterThan version b. Supplied versions must be
* Semantic Versions containing only numbers and dots (e.g 1.4.0).
* @param a version of interest
* @param b version to compare a against
* @return Whether version a is greaterThan version b
*/
greaterThan(a: string, b: string): boolean {
this.assertValidSemVer('a', a);
this.assertValidSemVer('b', b);
if (a === b) {
return false;
}
const sortedVersions = semverSort.desc([a, b]);
const isAGreaterThanB = sortedVersions[0] === a;
return isAGreaterThanB;
},
assertValidSemVer(variableName: string, version: string): void {
if (!version.match(SEM_VER_REGEX)) {
throw new Error(
`SemVer versions should only contain numbers and dots. Encountered: ${variableName} = ${version}`,
);
}
},
getLatestVersion(versions: string[]): string {
_.each(versions, version => {
this.assertValidSemVer('version', version);
});
const sortedVersions = semverSort.desc(versions);
return sortedVersions[0];
},
};

View File

@@ -1,10 +1,11 @@
import * as fs from 'fs';
import lernaGetPackages = require('lerna-get-packages');
import * as _ from 'lodash';
import { exec as execAsync } from 'promisify-child-process';
import { constants } from '../constants';
import { UpdatedPackage } from '../types';
import { GitTagsByPackageName, UpdatedPackage } from '../types';
import { changelogUtils } from './changelog_utils';
export const utils = {
log(...args: any[]): void {
@@ -17,11 +18,6 @@ export const utils = {
const newPatchVersion = `${versionSegments[0]}.${versionSegments[1]}.${newPatch}`;
return newPatchVersion;
},
async prettifyAsync(filePath: string, cwd: string): Promise<void> {
await execAsync(`prettier --write ${filePath} --config .prettierrc`, {
cwd,
});
},
async getUpdatedLernaPackagesAsync(shouldIncludePrivate: boolean): Promise<LernaPackage[]> {
const updatedPublicPackages = await this.getLernaUpdatedPackagesAsync(shouldIncludePrivate);
const updatedPackageNames = _.map(updatedPublicPackages, pkg => pkg.name);
@@ -43,22 +39,82 @@ export const utils = {
}
return updatedPackages;
},
getChangelogJSONIfExists(changelogPath: string): string | undefined {
try {
const changelogJSON = fs.readFileSync(changelogPath, 'utf-8');
return changelogJSON;
} catch (err) {
return undefined;
async getNextPackageVersionAsync(
currentVersion: string,
packageName: string,
packageLocation: string,
): Promise<string> {
let nextVersion;
const changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, packageLocation);
if (_.isEmpty(changelog)) {
nextVersion = this.getNextPatchVersion(currentVersion);
}
const lastEntry = changelog[0];
nextVersion =
lastEntry.version === currentVersion ? this.getNextPatchVersion(currentVersion) : lastEntry.version;
return nextVersion;
},
async getRemoteGitTagsAsync(): Promise<string[]> {
const result = await execAsync(`git ls-remote --tags`, {
cwd: constants.monorepoRootPath,
});
const tagsString = result.stdout;
const tagOutputs: string[] = tagsString.split('\n');
const tags = _.compact(
_.map(tagOutputs, tagOutput => {
const tag = tagOutput.split('refs/tags/')[1];
// Tags with `^{}` are duplicateous so we ignore them
// Source: https://stackoverflow.com/questions/15472107/when-listing-git-ls-remote-why-theres-after-the-tag-name
if (_.endsWith(tag, '^{}')) {
return undefined;
}
return tag;
}),
);
return tags;
},
async getLocalGitTagsAsync(): Promise<string[]> {
const result = await execAsync(`git tags`, {
cwd: constants.monorepoRootPath,
});
const tagsString = result.stdout;
const tags = tagsString.split('\n');
return tags;
},
async getGitTagsByPackageNameAsync(packageNames: string[], gitTags: string[]): Promise<GitTagsByPackageName> {
const tagVersionByPackageName: GitTagsByPackageName = {};
_.each(gitTags, tag => {
const packageNameIfExists = _.find(packageNames, name => {
return _.includes(tag, `${name}@`);
});
if (_.isUndefined(packageNameIfExists)) {
return; // ignore tags not related to a package we care about.
}
const splitTag = tag.split(`${packageNameIfExists}@`);
if (splitTag.length !== 2) {
throw new Error(`Unexpected tag name found: ${tag}`);
}
const version = splitTag[1];
(tagVersionByPackageName[packageNameIfExists] || (tagVersionByPackageName[packageNameIfExists] = [])).push(
version,
);
});
return tagVersionByPackageName;
},
async removeLocalTagAsync(tagName: string): Promise<void> {
const result = await execAsync(`git tag -d ${tagName}`, {
cwd: constants.monorepoRootPath,
});
if (!_.isEmpty(result.stderr)) {
throw new Error(`Failed to delete local git tag. Got err: ${result.stderr}`);
}
},
getChangelogJSONOrCreateIfMissing(changelogPath: string): string {
const changelogIfExists = this.getChangelogJSONIfExists(changelogPath);
if (_.isUndefined(changelogIfExists)) {
// If none exists, create new, empty one.
const emptyChangelogJSON = JSON.stringify([]);
fs.writeFileSync(changelogPath, emptyChangelogJSON);
return emptyChangelogJSON;
async removeRemoteTagAsync(tagName: string): Promise<void> {
const result = await execAsync(`git push origin ${tagName}`, {
cwd: constants.monorepoRootPath,
});
if (!_.isEmpty(result.stderr)) {
throw new Error(`Failed to delete remote git tag. Got err: ${result.stderr}`);
}
return changelogIfExists;
},
};