Skip to content

Commit

Permalink
feat: add npm:release:validate command (#122)
Browse files Browse the repository at this point in the history
* feat: add npm:release:validate command

* chore: update topics
  • Loading branch information
mdonnalley authored May 20, 2021
1 parent 01c59ae commit 418ca2e
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 95 deletions.
5 changes: 5 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"plugin": "@salesforce/plugin-release-management",
"flags": ["dryrun", "install", "json", "loglevel", "npmaccess", "npmtag", "prerelease", "sign"]
},
{
"command": "npm:release:validate",
"plugin": "@salesforce/plugin-release-management",
"flags": ["json", "loglevel", "verbose"]
},
{
"command": "repositories",
"plugin": "@salesforce/plugin-release-management",
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
},
"package": {
"description": "work with npm projects"
},
"release": {
"description": "validate npm releases"
}
}
},
Expand Down
48 changes: 48 additions & 0 deletions src/commands/npm/release/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { flags, FlagsConfig, SfdxCommand } from '@salesforce/command';
import { isMonoRepo, LernaRepo } from '../../../repository';
import { Package } from '../../../package';
import { CommitInspection, inspectCommits } from '../../../inspectCommits';

type PackageCommits = CommitInspection & {
name: string;
currentVersion: string;
};

type Response = {
shouldRelease: boolean;
packages?: PackageCommits[];
};

export default class Validate extends SfdxCommand {
public static readonly description =
'inspects the git commits to see if there are any commits that will warrant a new release';
public static readonly flagsConfig: FlagsConfig = {
verbose: flags.builtin({
description: 'show all commits for all packages (only works with --json flag)',
}),
};

public async run(): Promise<Response> {
const isLerna = await isMonoRepo();
const packages = isLerna ? await LernaRepo.getPackages() : [await Package.create()];
const responses: PackageCommits[] = [];
for (const pkg of packages) {
const commitInspection = await inspectCommits(pkg, isLerna);
const response = Object.assign(commitInspection, {
name: pkg.name,
currentVersion: pkg.packageJson.version,
});
responses.push(response);
}
const shouldRelease = responses.some((resp) => !!resp.shouldRelease);
this.ux.log(shouldRelease.toString());
return this.flags.verbose ? { shouldRelease, packages: responses } : { shouldRelease };
}
}
2 changes: 1 addition & 1 deletion src/commands/typescript/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default class Update extends SfdxCommand {
}

private async getPackages(): Promise<Package[]> {
return this.repo instanceof LernaRepo ? await this.repo.getPackages() : [this.repo.package];
return this.repo instanceof LernaRepo ? await LernaRepo.getPackages() : [this.repo.package];
}

private async updateEsTargetConfig(packagePath: string): Promise<void> {
Expand Down
98 changes: 98 additions & 0 deletions src/inspectCommits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import * as os from 'os';
import { Readable } from 'stream';
import { exec } from 'shelljs';
import * as conventionalCommitsParser from 'conventional-commits-parser';
import * as conventionalChangelogPresetLoader from 'conventional-changelog-preset-loader';
import { Nullable } from '@salesforce/ts-types';
import { Package } from './package';

export interface Commit {
type: Nullable<string>;
header: Nullable<string>;
body: Nullable<string>;
}

export interface CommitInspection {
releasableCommits: Commit[];
unreleasableCommits: Commit[];
nextVersionIsHardcoded: boolean;
shouldRelease: boolean;
}

/**
* If the commit type isn't fix (patch bump), feat (minor bump), or breaking (major bump),
* then standard-version always defaults to a patch bump.
* See https://github.com/conventional-changelog/standard-version/issues/577
*
* We, however, don't want to publish a new version for chore, docs, etc. So we analyze
* the commits to see if any of them indicate that a new release should be published.
*/
export async function inspectCommits(pkg: Package, lerna = false): Promise<CommitInspection> {
const skippableCommitTypes = ['chore', 'style', 'docs', 'ci', 'test'];

// find the latest git tag so that we can get all the commits that have happened since
const tags = exec('git fetch --tags && git tag', { silent: true }).stdout.split(os.EOL);
const latestTag = lerna
? tags.find((tag) => tag.includes(`${pkg.name}@${pkg.npmPackage.version}`)) || ''
: tags.find((tag) => tag.includes(pkg.npmPackage.version));
// import the default commit parser configuration
const defaultConfigPath = require.resolve('conventional-changelog-conventionalcommits');
const configuration = await conventionalChangelogPresetLoader({ name: defaultConfigPath });

const commits: Commit[] = await new Promise((resolve) => {
const DELIMITER = 'SPLIT';
const gitLogCommand = lerna
? `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges -- ${pkg.location}`
: `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges`;
const gitLog = exec(gitLogCommand, { silent: true })
.stdout.split(`${DELIMITER}${os.EOL}`)
.filter((c) => !!c);
const readable = Readable.from(gitLog);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore because the type exported from conventionalCommitsParser is wrong
const parser = readable.pipe(conventionalCommitsParser(configuration.parserOpts));
const allCommits: Commit[] = [];
parser.on('data', (commit: Commit) => allCommits.push(commit));
parser.on('finish', () => resolve(allCommits));
});

const nextVersionIsHardcoded = pkg.nextVersionIsHardcoded();
// All commits are releasable if the version hardcoded in the package.json
// In this scenario, we want to publish regardless of the commit types
if (nextVersionIsHardcoded) {
return {
releasableCommits: commits,
unreleasableCommits: [],
nextVersionIsHardcoded,
shouldRelease: true,
};
}

const releasableCommits: Commit[] = [];
const unreleasableCommits: Commit[] = [];
for (const commit of commits) {
const headerIndicatesMajorChange = !!commit.header && commit.header.includes('!');
const bodyIndicatesMajorChange = !!commit.body && commit.body.includes('BREAKING');
const typeIsSkippable = skippableCommitTypes.includes(commit.type);
const isReleasable = !typeIsSkippable || bodyIndicatesMajorChange || headerIndicatesMajorChange;
if (isReleasable) {
releasableCommits.push(commit);
} else {
unreleasableCommits.push(commit);
}
}

return {
releasableCommits,
unreleasableCommits,
nextVersionIsHardcoded,
shouldRelease: nextVersionIsHardcoded || releasableCommits.length > 0,
};
}
6 changes: 3 additions & 3 deletions src/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import * as path from 'path';
import { exec } from 'shelljs';
import { exec, pwd } from 'shelljs';
import { fs, Logger, SfdxError } from '@salesforce/core';
import { AsyncOptionalCreatable } from '@salesforce/kit';
import { AnyJson, get } from '@salesforce/ts-types';
Expand Down Expand Up @@ -57,9 +57,9 @@ export class Package extends AsyncOptionalCreatable {
private nextVersion: string;
private registry: Registry;

public constructor(location?: string) {
public constructor(location: string) {
super();
this.location = location;
this.location = location || pwd().stdout;
this.registry = new Registry();
}

Expand Down
103 changes: 28 additions & 75 deletions src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,20 @@

import * as path from 'path';
import * as os from 'os';
import { Readable } from 'stream';
import * as glob from 'glob';
import { pwd } from 'shelljs';
import { AnyJson, ensureString, getString } from '@salesforce/ts-types';
import { UX } from '@salesforce/command';
import { exec, ShellString } from 'shelljs';
import { fs, Logger, SfdxError } from '@salesforce/core';
import { AsyncOptionalCreatable, Env, isEmpty, sleep } from '@salesforce/kit';
import { Nullable, isString } from '@salesforce/ts-types';
import { isString } from '@salesforce/ts-types';
import * as chalk from 'chalk';
import * as conventionalCommitsParser from 'conventional-commits-parser';
import * as conventionalChangelogPresetLoader from 'conventional-changelog-preset-loader';
import { api as packAndSignApi, SigningResponse } from './codeSigning/packAndSign';
import { upload } from './codeSigning/upload';
import { Package, VersionValidation } from './package';
import { Registry } from './registry';
import { inspectCommits } from './inspectCommits';

export type LernaJson = {
packages?: string[];
Expand All @@ -49,12 +47,6 @@ interface VersionsByPackage {
};
}

interface Commit {
type: Nullable<string>;
header: Nullable<string>;
body: Nullable<string>;
}

type PollFunction = () => boolean;

export async function isMonoRepo(): Promise<boolean> {
Expand Down Expand Up @@ -235,46 +227,8 @@ abstract class Repository extends AsyncOptionalCreatable<RepositoryOptions> {
* the commits to see if any of them indicate that a new release should be published.
*/
protected async isReleasable(pkg: Package, lerna = false): Promise<boolean> {
// Return true if the version bump is hardcoded in the package.json
// In this scenario, we want to publish regardless of the commit types
if (pkg.nextVersionIsHardcoded()) return true;

const skippableCommitTypes = ['chore', 'style', 'docs', 'ci', 'test'];

// find the latest git tag so that we can get all the commits that have happened since
const tags = this.execCommand('git fetch --tags && git tag', true).stdout.split(os.EOL);
const latestTag = lerna
? tags.find((tag) => tag.includes(`${pkg.name}@${pkg.npmPackage.version}`)) || ''
: tags.find((tag) => tag.includes(pkg.npmPackage.version));

// import the default commit parser configuration
const defaultConfigPath = require.resolve('conventional-changelog-conventionalcommits');
const configuration = await conventionalChangelogPresetLoader({ name: defaultConfigPath });

const commits: Commit[] = await new Promise((resolve) => {
const DELIMITER = 'SPLIT';
const gitLogCommand = lerna
? `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges -- ${pkg.location}`
: `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges`;
const gitLog = this.execCommand(gitLogCommand, true)
.stdout.split(`${DELIMITER}${os.EOL}`)
.filter((c) => !!c);
const readable = Readable.from(gitLog);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore because the type exported from conventionalCommitsParser is wrong
const parser = readable.pipe(conventionalCommitsParser(configuration.parserOpts));
const allCommits: Commit[] = [];
parser.on('data', (commit: Commit) => allCommits.push(commit));
parser.on('finish', () => resolve(allCommits));
});

const commitsThatWarrantRelease = commits.filter((commit) => {
const headerIndicatesMajorChange = !!commit.header && commit.header.includes('!');
const bodyIndicatesMajorChange = !!commit.body && commit.body.includes('BREAKING');
const typeIsSkippable = skippableCommitTypes.includes(commit.type);
return !typeIsSkippable || bodyIndicatesMajorChange || headerIndicatesMajorChange;
});
return commitsThatWarrantRelease.length > 0;
const commitInspection = await inspectCommits(pkg, lerna);
return commitInspection.shouldRelease;
}

public abstract getSuccessMessage(): string;
Expand All @@ -298,6 +252,28 @@ export class LernaRepo extends Repository {
super(options);
}

public static async getPackages(): Promise<Package[]> {
const pkgPaths = await LernaRepo.getPackagePaths();
const packages: Package[] = [];
for (const pkgPath of pkgPaths) {
packages.push(await Package.create(pkgPath));
}
return packages;
}

public static async getPackagePaths(): Promise<string[]> {
const workingDir = pwd().stdout;
const lernaJson = (await fs.readJson('lerna.json')) as LernaJson;
// https://github.com/lerna/lerna#lernajson
// "By default, lerna initializes the packages list as ["packages/*"]"
const packageGlobs = lernaJson.packages || ['*'];
const packages = packageGlobs
.map((pGlob) => glob.sync(pGlob))
.reduce((x, y) => x.concat(y), [])
.map((pkg) => path.join(workingDir, pkg));
return packages;
}

public validate(): VersionValidation[] {
return this.packages.map((pkg) => pkg.validateNextVersion());
}
Expand Down Expand Up @@ -366,18 +342,9 @@ export class LernaRepo extends Repository {
return `${header}${os.EOL}${successes}`;
}

public async getPackages(): Promise<Package[]> {
const pkgPaths = await this.getPackagePaths();
const packages: Package[] = [];
for (const pkgPath of pkgPaths) {
packages.push(await Package.create(pkgPath));
}
return packages;
}

protected async init(): Promise<void> {
this.logger = await Logger.child(this.constructor.name);
const pkgPaths = await this.getPackagePaths();
const pkgPaths = await LernaRepo.getPackagePaths();
const nextVersions = this.determineNextVersionByPackage();
if (!isEmpty(nextVersions)) {
for (const pkgPath of pkgPaths) {
Expand All @@ -392,19 +359,6 @@ export class LernaRepo extends Repository {
}
}

private async getPackagePaths(): Promise<string[]> {
const workingDir = pwd().stdout;
const lernaJson = (await fs.readJson('lerna.json')) as LernaJson;
// https://github.com/lerna/lerna#lernajson
// "By default, lerna initializes the packages list as ["packages/*"]"
const packageGlobs = lernaJson.packages || ['packages/*'];
const packages = packageGlobs
.map((pGlob) => glob.sync(pGlob))
.reduce((x, y) => x.concat(y), [])
.map((pkg) => path.join(workingDir, pkg));
return packages;
}

private determineNextVersionByPackage(): VersionsByPackage {
const currentVersionRegex = /(?<=:\s)([0-9]{1,}\.|.){2,}(?=\s=>)/gi;
const nextVersionsRegex = /(?<==>\s)([0-9]{1,}\.|.){2,}/gi;
Expand Down Expand Up @@ -502,9 +456,8 @@ export class SinglePackageRepo extends Repository {
}

protected async init(): Promise<void> {
const packagePath = pwd().stdout;
this.logger = await Logger.child(this.constructor.name);
this.package = await Package.create(packagePath);
this.package = await Package.create();
this.shouldBePublished = await this.isReleasable(this.package);
this.nextVersion = this.determineNextVersion();
this.package.setNextVersion(this.nextVersion);
Expand Down
2 changes: 1 addition & 1 deletion test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('Package', () => {
name: pkgName,
version: '1.0.0',
});
expect(readStub.firstCall.calledWith('package.json')).be.true;
expect(readStub.firstCall.firstArg.endsWith('package.json')).be.true;
});

it('should read the package.json in the package location', async () => {
Expand Down
Loading

0 comments on commit 418ca2e

Please sign in to comment.