Add more prepublish checks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
@@ -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`);
|
||||
}
|
||||
|
||||
|
@@ -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[];
|
||||
}
|
||||
|
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
28
packages/monorepo-scripts/src/utils/npm_utils.ts
Normal file
28
packages/monorepo-scripts/src/utils/npm_utils.ts
Normal 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;
|
||||
},
|
||||
};
|
56
packages/monorepo-scripts/src/utils/semver_utils.ts
Normal file
56
packages/monorepo-scripts/src/utils/semver_utils.ts
Normal 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];
|
||||
},
|
||||
};
|
@@ -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;
|
||||
},
|
||||
};
|
||||
|
Reference in New Issue
Block a user