Skip to content

Commit 418ca2e

Browse files
authored
feat: add npm:release:validate command (#122)
* feat: add npm:release:validate command * chore: update topics
1 parent 01c59ae commit 418ca2e

File tree

9 files changed

+213
-95
lines changed

9 files changed

+213
-95
lines changed

command-snapshot.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
"plugin": "@salesforce/plugin-release-management",
3030
"flags": ["dryrun", "install", "json", "loglevel", "npmaccess", "npmtag", "prerelease", "sign"]
3131
},
32+
{
33+
"command": "npm:release:validate",
34+
"plugin": "@salesforce/plugin-release-management",
35+
"flags": ["json", "loglevel", "verbose"]
36+
},
3237
{
3338
"command": "repositories",
3439
"plugin": "@salesforce/plugin-release-management",

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@
123123
},
124124
"package": {
125125
"description": "work with npm projects"
126+
},
127+
"release": {
128+
"description": "validate npm releases"
126129
}
127130
}
128131
},

src/commands/npm/release/validate.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { flags, FlagsConfig, SfdxCommand } from '@salesforce/command';
9+
import { isMonoRepo, LernaRepo } from '../../../repository';
10+
import { Package } from '../../../package';
11+
import { CommitInspection, inspectCommits } from '../../../inspectCommits';
12+
13+
type PackageCommits = CommitInspection & {
14+
name: string;
15+
currentVersion: string;
16+
};
17+
18+
type Response = {
19+
shouldRelease: boolean;
20+
packages?: PackageCommits[];
21+
};
22+
23+
export default class Validate extends SfdxCommand {
24+
public static readonly description =
25+
'inspects the git commits to see if there are any commits that will warrant a new release';
26+
public static readonly flagsConfig: FlagsConfig = {
27+
verbose: flags.builtin({
28+
description: 'show all commits for all packages (only works with --json flag)',
29+
}),
30+
};
31+
32+
public async run(): Promise<Response> {
33+
const isLerna = await isMonoRepo();
34+
const packages = isLerna ? await LernaRepo.getPackages() : [await Package.create()];
35+
const responses: PackageCommits[] = [];
36+
for (const pkg of packages) {
37+
const commitInspection = await inspectCommits(pkg, isLerna);
38+
const response = Object.assign(commitInspection, {
39+
name: pkg.name,
40+
currentVersion: pkg.packageJson.version,
41+
});
42+
responses.push(response);
43+
}
44+
const shouldRelease = responses.some((resp) => !!resp.shouldRelease);
45+
this.ux.log(shouldRelease.toString());
46+
return this.flags.verbose ? { shouldRelease, packages: responses } : { shouldRelease };
47+
}
48+
}

src/commands/typescript/update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export default class Update extends SfdxCommand {
6363
}
6464

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

6969
private async updateEsTargetConfig(packagePath: string): Promise<void> {

src/inspectCommits.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import * as os from 'os';
9+
import { Readable } from 'stream';
10+
import { exec } from 'shelljs';
11+
import * as conventionalCommitsParser from 'conventional-commits-parser';
12+
import * as conventionalChangelogPresetLoader from 'conventional-changelog-preset-loader';
13+
import { Nullable } from '@salesforce/ts-types';
14+
import { Package } from './package';
15+
16+
export interface Commit {
17+
type: Nullable<string>;
18+
header: Nullable<string>;
19+
body: Nullable<string>;
20+
}
21+
22+
export interface CommitInspection {
23+
releasableCommits: Commit[];
24+
unreleasableCommits: Commit[];
25+
nextVersionIsHardcoded: boolean;
26+
shouldRelease: boolean;
27+
}
28+
29+
/**
30+
* If the commit type isn't fix (patch bump), feat (minor bump), or breaking (major bump),
31+
* then standard-version always defaults to a patch bump.
32+
* See https://github.com/conventional-changelog/standard-version/issues/577
33+
*
34+
* We, however, don't want to publish a new version for chore, docs, etc. So we analyze
35+
* the commits to see if any of them indicate that a new release should be published.
36+
*/
37+
export async function inspectCommits(pkg: Package, lerna = false): Promise<CommitInspection> {
38+
const skippableCommitTypes = ['chore', 'style', 'docs', 'ci', 'test'];
39+
40+
// find the latest git tag so that we can get all the commits that have happened since
41+
const tags = exec('git fetch --tags && git tag', { silent: true }).stdout.split(os.EOL);
42+
const latestTag = lerna
43+
? tags.find((tag) => tag.includes(`${pkg.name}@${pkg.npmPackage.version}`)) || ''
44+
: tags.find((tag) => tag.includes(pkg.npmPackage.version));
45+
// import the default commit parser configuration
46+
const defaultConfigPath = require.resolve('conventional-changelog-conventionalcommits');
47+
const configuration = await conventionalChangelogPresetLoader({ name: defaultConfigPath });
48+
49+
const commits: Commit[] = await new Promise((resolve) => {
50+
const DELIMITER = 'SPLIT';
51+
const gitLogCommand = lerna
52+
? `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges -- ${pkg.location}`
53+
: `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges`;
54+
const gitLog = exec(gitLogCommand, { silent: true })
55+
.stdout.split(`${DELIMITER}${os.EOL}`)
56+
.filter((c) => !!c);
57+
const readable = Readable.from(gitLog);
58+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
59+
// @ts-ignore because the type exported from conventionalCommitsParser is wrong
60+
const parser = readable.pipe(conventionalCommitsParser(configuration.parserOpts));
61+
const allCommits: Commit[] = [];
62+
parser.on('data', (commit: Commit) => allCommits.push(commit));
63+
parser.on('finish', () => resolve(allCommits));
64+
});
65+
66+
const nextVersionIsHardcoded = pkg.nextVersionIsHardcoded();
67+
// All commits are releasable if the version hardcoded in the package.json
68+
// In this scenario, we want to publish regardless of the commit types
69+
if (nextVersionIsHardcoded) {
70+
return {
71+
releasableCommits: commits,
72+
unreleasableCommits: [],
73+
nextVersionIsHardcoded,
74+
shouldRelease: true,
75+
};
76+
}
77+
78+
const releasableCommits: Commit[] = [];
79+
const unreleasableCommits: Commit[] = [];
80+
for (const commit of commits) {
81+
const headerIndicatesMajorChange = !!commit.header && commit.header.includes('!');
82+
const bodyIndicatesMajorChange = !!commit.body && commit.body.includes('BREAKING');
83+
const typeIsSkippable = skippableCommitTypes.includes(commit.type);
84+
const isReleasable = !typeIsSkippable || bodyIndicatesMajorChange || headerIndicatesMajorChange;
85+
if (isReleasable) {
86+
releasableCommits.push(commit);
87+
} else {
88+
unreleasableCommits.push(commit);
89+
}
90+
}
91+
92+
return {
93+
releasableCommits,
94+
unreleasableCommits,
95+
nextVersionIsHardcoded,
96+
shouldRelease: nextVersionIsHardcoded || releasableCommits.length > 0,
97+
};
98+
}

src/package.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import * as path from 'path';
8-
import { exec } from 'shelljs';
8+
import { exec, pwd } from 'shelljs';
99
import { fs, Logger, SfdxError } from '@salesforce/core';
1010
import { AsyncOptionalCreatable } from '@salesforce/kit';
1111
import { AnyJson, get } from '@salesforce/ts-types';
@@ -57,9 +57,9 @@ export class Package extends AsyncOptionalCreatable {
5757
private nextVersion: string;
5858
private registry: Registry;
5959

60-
public constructor(location?: string) {
60+
public constructor(location: string) {
6161
super();
62-
this.location = location;
62+
this.location = location || pwd().stdout;
6363
this.registry = new Registry();
6464
}
6565

src/repository.ts

Lines changed: 28 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,20 @@
77

88
import * as path from 'path';
99
import * as os from 'os';
10-
import { Readable } from 'stream';
1110
import * as glob from 'glob';
1211
import { pwd } from 'shelljs';
1312
import { AnyJson, ensureString, getString } from '@salesforce/ts-types';
1413
import { UX } from '@salesforce/command';
1514
import { exec, ShellString } from 'shelljs';
1615
import { fs, Logger, SfdxError } from '@salesforce/core';
1716
import { AsyncOptionalCreatable, Env, isEmpty, sleep } from '@salesforce/kit';
18-
import { Nullable, isString } from '@salesforce/ts-types';
17+
import { isString } from '@salesforce/ts-types';
1918
import * as chalk from 'chalk';
20-
import * as conventionalCommitsParser from 'conventional-commits-parser';
21-
import * as conventionalChangelogPresetLoader from 'conventional-changelog-preset-loader';
2219
import { api as packAndSignApi, SigningResponse } from './codeSigning/packAndSign';
2320
import { upload } from './codeSigning/upload';
2421
import { Package, VersionValidation } from './package';
2522
import { Registry } from './registry';
23+
import { inspectCommits } from './inspectCommits';
2624

2725
export type LernaJson = {
2826
packages?: string[];
@@ -49,12 +47,6 @@ interface VersionsByPackage {
4947
};
5048
}
5149

52-
interface Commit {
53-
type: Nullable<string>;
54-
header: Nullable<string>;
55-
body: Nullable<string>;
56-
}
57-
5850
type PollFunction = () => boolean;
5951

6052
export async function isMonoRepo(): Promise<boolean> {
@@ -235,46 +227,8 @@ abstract class Repository extends AsyncOptionalCreatable<RepositoryOptions> {
235227
* the commits to see if any of them indicate that a new release should be published.
236228
*/
237229
protected async isReleasable(pkg: Package, lerna = false): Promise<boolean> {
238-
// Return true if the version bump is hardcoded in the package.json
239-
// In this scenario, we want to publish regardless of the commit types
240-
if (pkg.nextVersionIsHardcoded()) return true;
241-
242-
const skippableCommitTypes = ['chore', 'style', 'docs', 'ci', 'test'];
243-
244-
// find the latest git tag so that we can get all the commits that have happened since
245-
const tags = this.execCommand('git fetch --tags && git tag', true).stdout.split(os.EOL);
246-
const latestTag = lerna
247-
? tags.find((tag) => tag.includes(`${pkg.name}@${pkg.npmPackage.version}`)) || ''
248-
: tags.find((tag) => tag.includes(pkg.npmPackage.version));
249-
250-
// import the default commit parser configuration
251-
const defaultConfigPath = require.resolve('conventional-changelog-conventionalcommits');
252-
const configuration = await conventionalChangelogPresetLoader({ name: defaultConfigPath });
253-
254-
const commits: Commit[] = await new Promise((resolve) => {
255-
const DELIMITER = 'SPLIT';
256-
const gitLogCommand = lerna
257-
? `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges -- ${pkg.location}`
258-
: `git log --format=%B%n-hash-%n%H%n${DELIMITER} ${latestTag}..HEAD --no-merges`;
259-
const gitLog = this.execCommand(gitLogCommand, true)
260-
.stdout.split(`${DELIMITER}${os.EOL}`)
261-
.filter((c) => !!c);
262-
const readable = Readable.from(gitLog);
263-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
264-
// @ts-ignore because the type exported from conventionalCommitsParser is wrong
265-
const parser = readable.pipe(conventionalCommitsParser(configuration.parserOpts));
266-
const allCommits: Commit[] = [];
267-
parser.on('data', (commit: Commit) => allCommits.push(commit));
268-
parser.on('finish', () => resolve(allCommits));
269-
});
270-
271-
const commitsThatWarrantRelease = commits.filter((commit) => {
272-
const headerIndicatesMajorChange = !!commit.header && commit.header.includes('!');
273-
const bodyIndicatesMajorChange = !!commit.body && commit.body.includes('BREAKING');
274-
const typeIsSkippable = skippableCommitTypes.includes(commit.type);
275-
return !typeIsSkippable || bodyIndicatesMajorChange || headerIndicatesMajorChange;
276-
});
277-
return commitsThatWarrantRelease.length > 0;
230+
const commitInspection = await inspectCommits(pkg, lerna);
231+
return commitInspection.shouldRelease;
278232
}
279233

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

255+
public static async getPackages(): Promise<Package[]> {
256+
const pkgPaths = await LernaRepo.getPackagePaths();
257+
const packages: Package[] = [];
258+
for (const pkgPath of pkgPaths) {
259+
packages.push(await Package.create(pkgPath));
260+
}
261+
return packages;
262+
}
263+
264+
public static async getPackagePaths(): Promise<string[]> {
265+
const workingDir = pwd().stdout;
266+
const lernaJson = (await fs.readJson('lerna.json')) as LernaJson;
267+
// https://github.com/lerna/lerna#lernajson
268+
// "By default, lerna initializes the packages list as ["packages/*"]"
269+
const packageGlobs = lernaJson.packages || ['*'];
270+
const packages = packageGlobs
271+
.map((pGlob) => glob.sync(pGlob))
272+
.reduce((x, y) => x.concat(y), [])
273+
.map((pkg) => path.join(workingDir, pkg));
274+
return packages;
275+
}
276+
301277
public validate(): VersionValidation[] {
302278
return this.packages.map((pkg) => pkg.validateNextVersion());
303279
}
@@ -366,18 +342,9 @@ export class LernaRepo extends Repository {
366342
return `${header}${os.EOL}${successes}`;
367343
}
368344

369-
public async getPackages(): Promise<Package[]> {
370-
const pkgPaths = await this.getPackagePaths();
371-
const packages: Package[] = [];
372-
for (const pkgPath of pkgPaths) {
373-
packages.push(await Package.create(pkgPath));
374-
}
375-
return packages;
376-
}
377-
378345
protected async init(): Promise<void> {
379346
this.logger = await Logger.child(this.constructor.name);
380-
const pkgPaths = await this.getPackagePaths();
347+
const pkgPaths = await LernaRepo.getPackagePaths();
381348
const nextVersions = this.determineNextVersionByPackage();
382349
if (!isEmpty(nextVersions)) {
383350
for (const pkgPath of pkgPaths) {
@@ -392,19 +359,6 @@ export class LernaRepo extends Repository {
392359
}
393360
}
394361

395-
private async getPackagePaths(): Promise<string[]> {
396-
const workingDir = pwd().stdout;
397-
const lernaJson = (await fs.readJson('lerna.json')) as LernaJson;
398-
// https://github.com/lerna/lerna#lernajson
399-
// "By default, lerna initializes the packages list as ["packages/*"]"
400-
const packageGlobs = lernaJson.packages || ['packages/*'];
401-
const packages = packageGlobs
402-
.map((pGlob) => glob.sync(pGlob))
403-
.reduce((x, y) => x.concat(y), [])
404-
.map((pkg) => path.join(workingDir, pkg));
405-
return packages;
406-
}
407-
408362
private determineNextVersionByPackage(): VersionsByPackage {
409363
const currentVersionRegex = /(?<=:\s)([0-9]{1,}\.|.){2,}(?=\s=>)/gi;
410364
const nextVersionsRegex = /(?<==>\s)([0-9]{1,}\.|.){2,}/gi;
@@ -502,9 +456,8 @@ export class SinglePackageRepo extends Repository {
502456
}
503457

504458
protected async init(): Promise<void> {
505-
const packagePath = pwd().stdout;
506459
this.logger = await Logger.child(this.constructor.name);
507-
this.package = await Package.create(packagePath);
460+
this.package = await Package.create();
508461
this.shouldBePublished = await this.isReleasable(this.package);
509462
this.nextVersion = this.determineNextVersion();
510463
this.package.setNextVersion(this.nextVersion);

test/package.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('Package', () => {
3636
name: pkgName,
3737
version: '1.0.0',
3838
});
39-
expect(readStub.firstCall.calledWith('package.json')).be.true;
39+
expect(readStub.firstCall.firstArg.endsWith('package.json')).be.true;
4040
});
4141

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

0 commit comments

Comments
 (0)